An idempotent way to run shell scripts.
Project description
potent
/ˈpōtnt/
adjective
having great power, influence, or effect.
A CLI for running (idem)potent shell scripts across directories.
Your script runs line-by-line in each directory. If it fails, subsequent runs of the script will pick up with the first command that hasn't run successfully yet.
example.plan.json
├── ✅ aardvark
│ ├── ✅ git-status
│ ├── ✅ switch-branch
│ ├── ✅ git-commit
│ └── ✅ git-push
├── ❌ badger
│ ├── ❌ git-status
│ ├── ⌛ switch-branch
│ ├── ⌛ git-commit
│ └── ⌛ git-push
└── ❌ camel
├── ✅ git-status
├── ❌ switch-branch
├── ⌛ git-commit
└── ⌛ git-push
Table of Contents
Project Status
potent is still under active development. It's ready for basic use, but not in production-critical systems.
On the road to v1.0.0, expect breaking schema changes, new Operations, small behavior changes, and general stability/productionalization improvements.
Before we reach 1.0.0, there may be breaking changes in any release; see the CHANGELOG for more details.
Install
Potent is available on PyPI: https://pypi.org/project/potent/. It's typically installed as a CLI using one of these tools:
uv:
uv tool install potent
brew install xavdid/projects/potent
pipx:
pipx install potent
Plans
Scripts are run as Plan files. They've got the extension .plan.json, but are standard json otherwise.
Plans have two main components:
- a list of directories the Plan will run in
- a list of Operations to perform in those directories
Plan files must conform to The Schema so that the invalid operations are surfaced early and loudly.
Directories
Potent support both absolute directories (/Users/somename/path/to/dir) and directories with a leading ~ (~/path/to/dir). In both cases, the directory must exist on the filesystem.
Authoring Plan files
Plans are just JSON, so you can write them by hand or generate them using other programs.
If you'd like in-editor hints, you can tell VSCode (or any other editor that supports JSON Schema) that *.plan.json files must conform to the schema by first adding the following to your VSCode settings:
{
"json.schemas": [
{
"fileMatch": ["*.plan.json"],
"url": "TBD"
}
]
}
Then, run potent schema url and paste the result into the url field above.
Specifying the schema will help with autocomplete and flag potential errors.
Command Plans
Command plans are special plan variant that will auto-reset themselves once a day. They're useful for plans you want to run periodically but are still idempotent within a daily window.
Plans as input
Most CLI commands accept a plan as their main argument. You can pass either a path to a plan file (like ~/Desktop/my-plan.plan.json) or a simple string. In the latter case, potent will look in its config directory for a plan with the corresponding name. For example, potent run my-script tries to run the plan at ~/.config/potent/commands/my-script.plan.json.
The config directory respects the XDG_CONFIG_HOME environment variable.
Operations
Each Operation is identified by its unique slug field. Each of the Operations below describes a single bash command with well-defined (and validated) arguments. If you need more flexibility, check out the raw command Operation.
Available Operations
| Slug | Requires Config? |
|---|---|
create-pr |
☑️ |
enable-automerge |
☑️ |
git-add |
☑️ |
git-commit |
☑️ |
git-pull |
|
git-push |
|
git-status |
|
git-switch |
☑️ |
manual-confirmation |
|
raw-command |
☑️ |
CreatePR
Creates a pull request using the gh CLI.
[!IMPORTANT] Requires the
ghCLI to be installed.
Slug: create-pr
Config
| name | type | description | default (if optional) |
|---|---|---|---|
title |
str |
The title of the PR. | |
body_text |
Optional[str] |
A string that will be used as the body of the PR. Exactly one of body_text or body_file is required. |
None |
body_file |
Optional[str] |
The absolute path to a readable file containing the full body of the PR. Exactly one of body_text or body_file is required. |
None |
draft |
bool |
Whether to open the PR in draft mode. | False |
base_branch |
Optional[str] |
The branch that you want to merge your changes into. Defaults to the repo's default branch. | None |
EnableAutomerge
Enables auto-merge for the PR corresponding to the current branch.
[!IMPORTANT] Requires the
ghCLI to be installed.
Slug: enable-automerge
Config (optional)
| name | type | description | default (if optional) |
|---|---|---|---|
mode |
"merge" | "squash" |
Sets the merge strategy for the PR. | "squash" |
GitAdd
Stages files in git.
Slug: git-add
Config
| name | type | description | default (if optional) |
|---|---|---|---|
all |
bool |
If true, add stage files. Exactly one of all or pattern must be specified. |
False |
pattern |
str |
The file(s) to stage. Is processed as a Python glob. Exactly one of all or pattern must be specified. |
"" |
GitCommit
Commits staged files in git.
Slug: git-commit
Config
| name | type | description | default (if optional) |
|---|---|---|---|
message |
str |
Commit message, submitted as is. | |
allow_empty |
bool |
If true, allows commits without changed/added files. | False |
GitPull
Pull from the remote repository.
Slug: git-pull
GitPush
Push to the remote repository.
Slug: git-push
GitStatus
Ensures that you have a clean working directory. If there are any modified or un-staged files, this step fails.
Slug: git-status
GitSwitch
Switches the local git branch. Can optionally create it if it's missing.
Slug: git-switch
Config
| name | type | description | default (if optional) |
|---|---|---|---|
branch |
str |
branch name | |
create_if_missing |
bool |
If true, tries creating the branch if switching to it fails | False |
ManualConfirmation
A step that always fails. To advance your plan, manually edit the plan file so each directory succeeds.
Useful for putting pauses into a multi-phase plan.
Slug: manual-confirmation
RawCommand
Runs a shell command. The step succeeds if the command exits 0 and fails otherwise. Useful for operations that potent doesn't support natively.
Slug: raw-command
Config
| name | type | description | default (if optional) |
|---|---|---|---|
arguments |
list[str] |
The arguments that will be passed into Python's subprocess.run() | |
name |
Optional[str] |
A name used to disambiguate this step in summaries. Useful if you have many raw-commands. |
None |
CLI Commands
describe
Print basic info about the plan, including the directories on which it acts and the steps involved.
Arguments
path(FILE, required): The location of a.plan.jsonfile. Can be a full path or a name. If a name, the named file must exist in the configured command directory.
init
Create an empty plan at the specified path. If the path resolves to the config directory, then it defaults to command mode. Otherwise, the default of plan is used.
Arguments
path(Path, required): The location in which to to create a blank plan file. Can be a full path or a name. If a name, the named file must not exist in the configured command directory.
reset
Reset the progress on a plan file so it can be run again from scratch.
Arguments
path(FILE, required): The location of a.plan.jsonfile. Can be a full path or a name. If a name, the named file must exist in the configured command directory.
run
Execute a plan file and then print its status.
Arguments
path(FILE, required): The location of a.plan.jsonfile. Can be a full path or a name. If a name, the named file must exist in the configured command directory.skip_reset(bool, optional): If supplied, don't automatically reset a command plan. Ignored for non-command plans.
schema
Tools to programmatically access the plan schema.
It includes the following subcommands:
urldump
schema url
Print the versioned url of Potent's JSON schema. Useful for getting in-editor completions or performing external validations.
schema dump
Dump the current schema to stdout. While the versioned url is simpler to use, the dumped schema will include any plugins you have installed, making it more complete & accurate for your use case.
status
Print the current state of a plan file, including the progress through each directory.
Arguments
path(FILE, required): The location of a.plan.jsonfile. Can be a full path or a name. If a name, the named file must exist in the configured command directory.
FAQ
Reading validation messages
The error you get when you have an invalid plan file can take a little getting used to. But don't panic! It's actually pretty easy to read.
The most common error you'll get is an invalid slug. It looks like:
ValidationError: 1 validation error for Plan
operations.0
Input tag 'bad-slug' found using 'slug' does not match any of the expected tags: 'git-pull',
'switch-branch', 'git-status', 'git-add', 'git-commit', 'git-push', 'create-pr',
'enable-automerge', 'raw-command' [type=union_tag_invalid, input_value={'comment': None,
'direct...', 'allow_empty': True}}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.11/v/union_tag_invalid
The lines tell you:
- what failed to validate
- its json path (in this case,
operations.0, the first element of thestepsarray) - the expected values (which the input doesn't match)
The next most common is missing a required key, which follows a similar pattern:
ValidationError: 1 validation error for Plan
operations.0.git-commit.config.message
Field required [type=missing, input_value={'allow_empty': True}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.11/v/missing
Line 2 is now even more descriptive: operations[0].config.message is an error of type=missing. More simply, a required key isn't there.
The last common error is an extra key:
operations.0.git-commit.config.bad_key
Extra inputs are not permitted [type=extra_forbidden, input_value=True, input_type=bool]
For further information visit https://errors.pydantic.dev/2.11/v/extra_forbidden
Only expected keys are allowed, and bad_key is not expected.
Demo
Let's say we've got 3 repos, aardvark, badger, and camel. In each one, we want to:
- ensure we have a clean working directory before proceeding
- switch to the demo branch
- create an empty commit
- push that branch
We can create a plan for that operation, demo.plan.json:
{
"version": "v1",
"operations": [
{
"slug": "git-status"
},
{
"slug": "switch-branch",
"config": {
"branch": "demo"
}
},
{
"slug": "git-commit",
"config": {
"message": "a cool demo commit",
"allow_empty": true
}
},
{
"slug": "git-push"
}
],
"directories": ["/potent-demo/a", "/potent-demo/b", "/potent-demo/c"]
}
Let's make sure it parses correctly:
% potent summarize demo.plan.json
/Users/david/projects/potent/example.plan.json
├── ⌛ aardvark
│ ├── ⌛ git-status
│ ├── ⌛ switch-branch
│ ├── ⌛ git-commit
│ └── ⌛ git-push
├── ⌛ badger
│ └── same steps as above
└── ⌛ camel
└── same steps as above
Looks good! Let's give it a run:
% potent run demo.plan.json
Running /Users/david/projects/potent/example.plan.json
────────────────────────────── 📂 aardvark 📂 ──────────────────────────────
╭─ step: git-status ─────────────────────────────────────────────────────╮
│ │
│ >>> git status --porcelain │
│ │
│ Working directory clean! │
│ │
╰─ result: Succeeded ──────────────────────────────────────────────────────╯
╭─ step: switch-branch ────────────────────────────────────────────────────╮
│ │
│ >>> git switch demo │
│ │
│ Switched to branch 'demo' │
│ │
╰─ result: Succeeded ──────────────────────────────────────────────────────╯
╭─ step: git-commit ───────────────────────────────────────────────────────╮
│ │
│ >>> git commit -m "a cool demo commit" --allow-empty │
│ │
│ [demo ef51deb] a cool demo commit │
│ │
╰─ result: Succeeded ──────────────────────────────────────────────────────╯
╭─ step: git-push ─────────────────────────────────────────────────────────╮
│ │
│ >>> git push │
│ │
│ To github.com:xavdid/potent-demo.git │
│ 879eaae..ef51deb demo -> demo │
│ │
╰─ result: Succeeded ──────────────────────────────────────────────────────╯
─────────────────────────────── 📂 badger 📂 ───────────────────────────────
╭─ step: git-status ─────────────────────────────────────────────────────╮
│ │
│ >>> git status --porcelain │
│ │
│ fatal: not a git repository (or any of the parent directories): .git │
│ │
╰─ result: Failed ─────────────────────────────────────────────────────────╯
─────────────────────────────── 📂 camel 📂 ────────────────────────────────
╭─ step: git-status ─────────────────────────────────────────────────────╮
│ │
│ >>> git status --porcelain │
│ │
│ Working directory clean! │
│ │
╰─ result: Succeeded ──────────────────────────────────────────────────────╯
╭─ step: switch-branch ────────────────────────────────────────────────────╮
│ │
│ >>> git switch demo │
│ │
│ fatal: invalid reference: demo │
│ │
╰─ result: Failed ─────────────────────────────────────────────────────────╯
───────────────────────────────── Summary ──────────────────────────────────
example.plan.json
├── ✅ aardvark
│ ├── ✅ git-status
│ ├── ✅ switch-branch
│ ├── ✅ git-commit
│ └── ✅ git-push
├── ❌ badger
│ ├── ❌ git-status
│ ├── ⌛ switch-branch
│ ├── ⌛ git-commit
│ └── ⌛ git-push
└── ❌ camel
├── ✅ git-status
├── ❌ switch-branch
├── ⌛ git-commit
└── ⌛ git-push
Oh no! Everything went great in aardvark, but it looks like I forgot to initialize the repo in badger and camel doesn't have the demo branch.
I'll run git init in badger and git checkout -b demo in camel to get us back on track. Let's run the script again:
Running /Users/david/projects/potent/example.plan.json
────────────────────────────── 📂 aardvark 📂 ──────────────────────────────
☑️ already finished
─────────────────────────────── 📂 badger 📂 ───────────────────────────────
╭─ step: git-status ─────────────────────────────────────────────────────╮
│ │
│ >>> git status --porcelain │
│ │
│ Working directory clean! │
│ │
╰─ result: Succeeded ──────────────────────────────────────────────────────╯
╭─ step: switch-branch ────────────────────────────────────────────────────╮
│ │
│ >>> git switch demo │
│ │
│ fatal: invalid reference: demo │
│ │
╰─ result: Failed ─────────────────────────────────────────────────────────╯
─────────────────────────────── 📂 camel 📂 ────────────────────────────────
╭─ step: git-status ─────────────────────────────────────────────────────╮
│ │
│ Already completed │
│ │
╰─ result: skipped ────────────────────────────────────────────────────────╯
╭─ step: switch-branch ────────────────────────────────────────────────────╮
│ │
│ >>> git switch demo │
│ │
│ Already on 'demo' │
│ │
╰─ result: Succeeded ──────────────────────────────────────────────────────╯
╭─ step: git-commit ───────────────────────────────────────────────────────╮
│ │
│ >>> git commit -m "a cool demo commit" --allow-empty │
│ │
│ a cool demo commit │
│ │
╰─ result: Succeeded ──────────────────────────────────────────────────────╯
╭─ step: git-push ─────────────────────────────────────────────────────────╮
│ │
│ >>> git push │
│ │
│ fatal: No configured push destination. │
│ Either specify the URL from the command-line or configure a remote │
│ repository using │
│ │
│ git remote add <name> <url> │
│ │
│ and then push using the remote name │
│ │
│ git push <name> │
│ │
╰─ result: Failed ─────────────────────────────────────────────────────────╯
───────────────────────────────── Summary ──────────────────────────────────
example.plan.json
├── ☑️ aardvark
├── ❌ badger
│ ├── ✅ git-status
│ ├── ❌ switch-branch
│ ├── ⌛ git-commit
│ └── ⌛ git-push
└── ❌ camel
├── ☑️ git-status
├── ✅ switch-branch
├── ✅ git-commit
└── ❌ git-push
The ✅ marks show what steps we completed this run, while ☑️ denotes a step we completed on a previous run.
aardvark was skipped since it was already done. We made progress in badger and camel despite erroring out because we can't switch branches without commits and there's nothing to push to.
But, I could resume my script from the middle, skipping any completed operations. This is the power of potent!
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file potent-0.5.0.tar.gz.
File metadata
- Download URL: potent-0.5.0.tar.gz
- Upload date:
- Size: 20.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
599979c3abc631aa341a4b9df91d9bf6ca8209506069d6ecdc45a8f82882cba9
|
|
| MD5 |
cb00eb1a9a872542745070355073a530
|
|
| BLAKE2b-256 |
0ae910be54c618a982b53759c11b4be607051073253a21c96399c2c82c245471
|
File details
Details for the file potent-0.5.0-py3-none-any.whl.
File metadata
- Download URL: potent-0.5.0-py3-none-any.whl
- Upload date:
- Size: 28.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
afd2cb37c32baa14852bd5c3b718889df7b188feab04b9da7be7d1b45f64f10f
|
|
| MD5 |
00273dd943030742e9dadbcb2c0ca93f
|
|
| BLAKE2b-256 |
e753e4b1eec5f4c67afe38af8024f2e77b903a7add13c80671389e7a93fbca44
|