Skip to content

Scripts ​

TumbleTrove Desktop can run scripts that packages declare in their hpm.toml manifest, directly from the launcher UI — no terminal needed. Two flavors:

  • Project scripts — run in the context of a prepared project, with the project's full Houdini environment loaded.
  • Package scripts — run against a single package directory on disk, handy for package authors iterating on a dev package.

Declaring scripts ​

In a package's hpm.toml:

toml
[package]
name = "my-package"
version = "1.0.0"

[scripts]
cook = "hython cook_all.py"
lint = "python -m ruff check ."
docs = "mkdocs serve"

Each entry is <name> = "<shell command>". The command runs with the current working directory set to the package's directory.

Project scripts ​

Project scripts are the union of all [scripts] entries across a project's installed packages. Open a prepared project and go to the Scripts panel to see them grouped by owning package.

Click Run on a script entry and the launcher:

  1. Sets the working directory to the package's directory inside the prepared project.
  2. Merges the project's Houdini env vars (HOUDINI_PATH, PYTHONPATH, etc.) with the user's current environment.
  3. Parses the command into argv (POSIX-style: "…" and '…' quote groups, \ escapes, $VAR / ${VAR} expansion against the merged env) and spawns the program directly — no shell. On Windows the no-console-flash flag is set so no blank terminal pops up. Scripts that use shell features (|, &&, > redirections, ;, etc.) fall back to sh -c / cmd /c.
  4. Forwards stdout/stderr to the desktop log file (stderr at warn, stdout at debug) so failed scripts stay diagnosable.

Scripts run in the background. You can run multiple at once. A running script can be cancelled from the UI — the spawned process is terminated.

Requirement: the project must be prepared. Scripts that rely on installed dependencies won't work against a project that's only been defined.

Package scripts ​

Package scripts run a script directly against a filesystem path, without a surrounding project. You'll use this when developing a package:

  • Right-click a dev package in the Packages tab → Run script → pick a script.
  • Or open the package's own detail view and run from the Scripts section.

Package scripts get the package's own env vars but not a project-level Houdini environment. If your script needs HOUDINI_PATH, run it as a project script instead.

Environment ​

Scripts inherit the user's environment plus the merged package/project env vars. Notable additions:

VariableSourceMeaning
HOUDINI_PATHproject (all packages)Houdini plugin search path
PYTHONPATHproject (all packages)Python module search path
HOUDINI_OTLSCAN_PATHproject (all packages)OTL scan path
TT_CLAUDE_PLUGINSper-packageClaude Code plugin dirs — see Claude Code Plugins
Custom env varsper-package hpm.tomlAnything the package author exports

Each package's entries are joined with the platform path separator (: on Unix, ; on Windows).

Cancellation ​

Running scripts are tracked by the app. Click the cancel button next to any running script to terminate it. The desktop kills the spawned process directly (SIGKILL on macOS/Linux, TerminateProcess on Windows) — child processes the script started are not killed automatically and may be left orphaned. If your script shells out to a long-running tool, take care to clean up explicitly.

Troubleshooting ​

Script not appearing? Confirm the package is installed and the project is prepared. Project scripts read from the installed copy in the project dir, not from the registry manifest.

Script fails with "command not found"? The launcher resolves the program name against PATH (or treats it as a literal path if it contains a separator). For Python tools, prefer python -m <tool> or invoke via a package-provided venv. HPM sets up ~/.hpm/venvs/<pkg>/ for packages with Python deps; activate it explicitly in your script if needed.

Console window flashes on Windows? The app sets CREATE_NO_WINDOW for all spawned scripts — if you still see a flash, it's likely the script itself re-spawning a cmd/PowerShell. Have the script redirect or use pythonw for GUI-less Python.

Reserved hook names (tt_*) ​

Script names beginning with tt_ are reserved as TumbleTrove hooks: the desktop runs them itself at specific lifecycle points instead of surfacing them as user-clickable buttons. They never appear in the project or package script menus, and run_project_script / run_package_script reject them outright.

Hooks live in the same [scripts] table as regular scripts — no separate section needed:

toml
[scripts]
cook = "hython cook_all.py"             # regular, user-invocable

tt_setup = "python wizard.py"           # reserved hook
HookWhen it runsOutputPurpose
tt_setupAutomatically when the user adds your package to a project and it declares an unfilled required variable; and on demand, any time, from the Configure… button on the package cardJSON env vars on stdout → saved as the project's env varsInteractive setup wizard, project-side configuration
tt_installFirst prepare_project after the package is added to the project (per-project state, not per-CAS-version). Does not re-fire on a re-enable.Side-effect only — stdout discardedInstall third-party dependencies (heavy, one-time)
tt_uninstallFirst prepare_project after the package is permanently removed from the project. Does not fire on a toggle-off.Side-effect onlySymmetric cleanup of what tt_install set up
tt_enableprepare_project after the package is added (right after tt_install) or after it's toggled back onSide-effect onlyStart a service / activate the package (light, per-toggle)
tt_disableprepare_project after the package is toggled off, or as the first step of a permanent removal of a previously-enabled packageSide-effect onlyStop a service / deactivate the package (light, per-toggle)
tt_prepareEvery launch_projectJSON env vars → ephemeral, merged into the launch process env onlyTokens, runtime-derived paths

JSON output schema (tt_setup, tt_prepare) ​

These hooks write a single JSON object to stdout. Both shorthand and explicit forms are accepted:

json
{
  "envVars": {
    "MY_TOKEN": "abc123",                              // shorthand: method = "set"
    "MY_PATH": { "value": "/usr/local/lib", "method": "prepend" }
  }
}

Empty stdout (zero exit) is allowed and means "no env var changes" — this is also how an interactive wizard reports that the user cancelled (see below). Non-zero exit or malformed JSON: tt_setup surfaces the error in the form UI and blocks the configure action; tt_prepare logs the failure but does not abort the launch (a stale env var from a flaky hook is usually less harmful than a refused launch). Stderr always streams to the desktop log under [<hook>:<package>].

Requiring a value your package can't know ​

A [runtime] entry declared with required = true and no value is a placeholder the project has to fill:

toml
[runtime]
TH_LICENSE_SERVER = { method = "set", required = true }

Omit value entirely — value = "" is a value, so hpm treats the entry as satisfied, installs cleanly, and hands Houdini an empty variable. An empty default is the easy way to ship a requirement nobody is told about.

Installing a project that hasn't supplied one fails outright — hpm won't generate Houdini's package.json without it. The desktop asks for it up front: the project editor renders the key as an empty row flagged required and warns until it has a value. That only works for prerequisites you declare this way, though; a value your package needs but never declares is invisible to the editor, and the user meets it as a failed install.

A tt_setup wizard is the natural partner for one. Declare the placeholder so the requirement is visible even before anyone runs anything, and have the wizard return that same key.

Declaring the pair is also what triggers the wizard on its own: when the user adds your package to a project and it declares an unfilled required key, the editor runs tt_setup rather than leaving them to find the Configure… button. It fires only for the declaring package's own unfilled keys, skips a package the user has disabled, and runs at most once per package per editor session — so a wizard the user closes without completing is not reopened.

Adding the package is the only moment it runs unprompted. Editing a project that already has your package never opens it, however the variable stands — that is what the Configure… button is for, and it is always available. This matters if your wizard writes keys your package doesn't declare, or if the user declines it: the declared row stays empty, and nothing will reopen the wizard on its own again. Say so in the required key's own description; the editor shows an unfilled required key in a warning that names the button.

Two consequences for authors: your wizard can be opened without an explicit click, so it must be safe to cancel; and if it exits without emitting the key it was called for, nothing retries it.

Exit 0 with empty stdout when the user cancels. That is the contract's "no env var changes", and it is the only way to signal a decline. A non-zero exit means the hook failed and is reported to the user as Configure failed for <package> — so exiting non-zero on cancel hands them an error for their own choice, and under autorun, for a window they never asked to open. Keep non-zero for real failures (your wizard couldn't start, a template was missing), and write the decline to stderr if you want it in the desktop log.

For tt_setup, every key you return is saved the same way: as one of the project's environment variables. What declaring a key in your [runtime] table changes is where the user finds it and when it goes away. A declared key is shown on your package's own card in the editor, and is retired with the package if the user removes it from the project. A key nothing declares — a license-server host, a studio share path, anything your wizard derives rather than declares — appears under Environment Variables and outlives your package. Declaring the keys your wizard writes is what keeps them grouped with it, and what lets the editor clean them up afterwards.

For tt_prepare, returned values stack on top of project-level env vars in memory only — they're never written back to the project on disk, so tokens and runtime-derived paths can't go stale.

The hooks spawn through the same path as user scripts (argv-direct for <exe> <args> shapes, shell fallback only when the source uses pipes/redirections/etc.), with HPM_PACKAGE_ROOT set and CWD pinned to the package directory. Wizards can open their own UI window — the desktop only watches stdout for the final JSON payload.

Context vars in tt_prepare ​

tt_prepare runs at launch, so the desktop forwards the project's TT_* context block as part of the hook's environment — handy when a hook needs to fetch a per-user secret or shape its output by project name without re-querying the API:

VariableValue
TT_DEBUG1 on a dev launch (packages linked to your local workspace), 0 on a production launch
TT_PROJECT_NAMEThe launching project's display name
TT_PROJECT_DIRAbsolute path to the prepared project directory
TT_HOUDINI_VERSIONThe version string the project is pinned to
TT_PROJECT_SCOPEpersonal for local projects, team for shared/team projects
TT_ORGANIZATION_SLUGOrg slug (team projects only — unset for personal)
TT_USER_EMAILSigned-in user's email
TT_USER_NAMEUsername, falling back to the email's local part

If a project explicitly sets one of these in its env-var overrides, the hook sees the override (same precedence as Houdini does). tt_setup, tt_install, tt_uninstall, tt_enable, and tt_disable do not receive TT_* — they run outside launch context.

Lifecycle hooks (tt_install, tt_uninstall, tt_enable, tt_disable) ​

The four lifecycle hooks pair up by purpose: tt_install / tt_uninstall are heavy one-time setup and teardown for 3rd-party tools that live outside the HPM-managed package dir; tt_enable / tt_disable are light per-toggle activate and deactivate of services. All four are fire-and-side-effect — their stdout is discarded.

Each project has a <project_dir>/.tt_install_state.json file tracking which packages have had tt_install run (installed) and which of those are currently enabled (enabled). On every prepare the desktop diffs that record against the project's current package list + per-package toggles and fires hooks accordingly:

  • Package newly added (and enabled): tt_install runs (post-sync, files present), then tt_enable. On success the package is added to both installed and enabled. Failed tt_install logs a warning and keeps the package out of the state file; the next prepare retries.
  • Package toggled off: tt_disable runs (pre-sync, files still on disk). The package moves out of enabled but stays in installed.
  • Package toggled back on: tt_enable runs (post-sync, files freshly reinstalled). tt_install does not re-fire — the heavy external setup persists across toggle cycles. The package moves back into enabled.
  • Package permanently removed while enabled: tt_disable runs first (deactivate), then tt_uninstall (cleanup). The package is dropped from both installed and enabled.
  • Package permanently removed while disabled: tt_uninstall cannot run — the package's files were wiped on the toggle-off prepare. The desktop logs a warning. If your package needs permanent-removal cleanup, put it in tt_disable rather than tt_uninstall, since tt_disable always runs while files are still on disk.

Project deletion does not run any of these hooks — the project files are deleted directly. If your package needs cleanup at project-deletion time, design tt_uninstall so it's also safe to not run (e.g. only writing into the project dir, which gets deleted with it).

Released under the proprietary TumbleTrove license.