@normful/picadillo 1.0.2

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.
package/cliff.toml ADDED
@@ -0,0 +1,94 @@
1
+ # git-cliff ~ configuration file
2
+ # https://git-cliff.org/docs/configuration
3
+
4
+
5
+ [changelog]
6
+ # A Tera template to be rendered for each release in the changelog.
7
+ # See https://keats.github.io/tera/docs/#introduction
8
+ body = """
9
+ {% if version %}\
10
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
11
+ {% else %}\
12
+ ## [unreleased]
13
+ {% endif %}\
14
+ {% for group, commits in commits | group_by(attribute="group") %}
15
+ ### {{ group | striptags | trim | upper_first }}
16
+ {% for commit in commits %}
17
+ - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
18
+ {% if commit.breaking %}[**breaking**] {% endif %}\
19
+ {{ commit.message | upper_first }}\
20
+ {% endfor %}
21
+ {% endfor %}
22
+ """
23
+ # Remove leading and trailing whitespaces from the changelog's body.
24
+ trim = true
25
+ # Render body even when there are no releases to process.
26
+ render_always = true
27
+ # An array of regex based postprocessors to modify the changelog.
28
+ postprocessors = [
29
+ # Replace the placeholder <REPO> with a URL.
30
+ #{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
31
+ ]
32
+ # render body even when there are no releases to process
33
+ # render_always = true
34
+ # output file path
35
+ # output = "test.md"
36
+
37
+ [git]
38
+ # Parse commits according to the conventional commits specification.
39
+ # See https://www.conventionalcommits.org
40
+ conventional_commits = true
41
+ # Exclude commits that do not match the conventional commits specification.
42
+ filter_unconventional = true
43
+ # Require all commits to be conventional.
44
+ # Takes precedence over filter_unconventional.
45
+ require_conventional = false
46
+ # Split commits on newlines, treating each line as an individual commit.
47
+ split_commits = false
48
+ # An array of regex based parsers to modify commit messages prior to further processing.
49
+ commit_preprocessors = [
50
+ # Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
51
+ #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
52
+ # Check spelling of the commit message using https://github.com/crate-ci/typos.
53
+ # If the spelling is incorrect, it will be fixed automatically.
54
+ #{ pattern = '.*', replace_command = 'typos --write-changes -' },
55
+ ]
56
+ # Prevent commits that are breaking from being excluded by commit parsers.
57
+ protect_breaking_commits = false
58
+ # An array of regex based parsers for extracting data from the commit message.
59
+ # Assigns commits to groups.
60
+ # Optionally sets the commit's scope and can decide to exclude commits from further processing.
61
+ commit_parsers = [
62
+ { message = "^feat", group = "<!-- 0 -->๐Ÿš€ Features" },
63
+ { message = "^fix", group = "<!-- 1 -->๐Ÿ› Bug Fixes" },
64
+ { message = "^doc", group = "<!-- 3 -->๐Ÿ“š Documentation" },
65
+ { message = "^perf", group = "<!-- 4 -->โšก Performance" },
66
+ { message = "^refactor", group = "<!-- 2 -->๐Ÿšœ Refactor" },
67
+ { message = "^style", group = "<!-- 5 -->๐ŸŽจ Styling" },
68
+ { message = "^test", group = "<!-- 6 -->๐Ÿงช Testing" },
69
+ { message = "^chore\\(release\\): prepare for", skip = true },
70
+ { message = "^chore\\(deps.*\\)", skip = true },
71
+ { message = "^chore\\(pr\\)", skip = true },
72
+ { message = "^chore\\(pull\\)", skip = true },
73
+ { message = "^chore|^ci", group = "<!-- 7 -->โš™๏ธ Miscellaneous Tasks" },
74
+ { body = ".*security", group = "<!-- 8 -->๐Ÿ›ก๏ธ Security" },
75
+ { message = "^revert", group = "<!-- 9 -->โ—€๏ธ Revert" },
76
+ { message = ".*", group = "<!-- 10 -->๐Ÿ’ผ Other" },
77
+ ]
78
+ # Exclude commits that are not matched by any commit parser.
79
+ filter_commits = false
80
+ # Fail on a commit that is not matched by any commit parser.
81
+ fail_on_unmatched_commit = false
82
+ # An array of link parsers for extracting external references, and turning them into URLs, using regex.
83
+ link_parsers = []
84
+ # Include only the tags that belong to the current branch.
85
+ use_branch_tags = false
86
+ # Order releases topologically instead of chronologically.
87
+ topo_order = false
88
+ # Order commits topologically instead of chronologically.
89
+ topo_order_commits = true
90
+ # Order of commits in each group/release within the changelog.
91
+ # Allowed values: newest, oldest
92
+ sort_commits = "oldest"
93
+ # Process submodules commits
94
+ recurse_submodules = false
File without changes
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -o errexit
4
+ set -o pipefail
5
+
6
+ LATEST_VERSION=$(node -p -e "require('./package.json').version")
7
+
8
+ if [ "$1" = "stdout" ]; then
9
+ git-cliff --output - --unreleased --tag $LATEST_VERSION
10
+ else
11
+ git-cliff --output './CHANGELOG.md' --tag $LATEST_VERSION
12
+ fi
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@normful/picadillo",
3
+ "version": "1.0.2",
4
+ "private": false,
5
+ "description": "Norman's pi coding agent commands, skills, extensions",
6
+ "keywords": [
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "coding-assistant",
10
+ "extensions",
11
+ "skills",
12
+ "ai",
13
+ "pi-package",
14
+ "pi-extension",
15
+ "pi-skill"
16
+ ],
17
+ "homepage": "https://github.com/normful/picadillo#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/normful/picadillo/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/normful/picadillo.git"
24
+ },
25
+ "license": "MIT",
26
+ "author": "normful",
27
+ "type": "commonjs",
28
+ "main": "none",
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "release": "release-it"
32
+ },
33
+ "peerDependencies": {
34
+ "@mariozechner/pi-coding-agent": "*",
35
+ "@mariozechner/pi-ai": "*",
36
+ "@mariozechner/pi-tui": "*",
37
+ "typescript": "^5"
38
+ },
39
+ "pi": {
40
+ "prompts": [],
41
+ "extensions": [],
42
+ "skills": [
43
+ "./run-in-tmux/SKILL.md"
44
+ ]
45
+ },
46
+ "devDependencies": {
47
+ "@types/bun": "latest",
48
+ "release-it": "^19.2.4"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
File without changes
File without changes
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: run-in-tmux
3
+ description: Use when the user wants to run commands in a new tmux session with split panes. Triggers on requests like "run X in tmux", "start a dev environment in tmux", or "open multiple terminals in tmux".
4
+ ---
5
+
6
+ # run-in-tmux
7
+
8
+ Creates a tmux session with one or more commands running in separate split panes. Automatically detects the git repository root and generates a unique session name.
9
+
10
+ ## When to use
11
+
12
+ - User wants to run development commands (e.g., `npm run dev`, `cargo watch`)
13
+ - User needs multiple terminal panes running different commands
14
+ - User wants to start a background process that persists after terminal closes
15
+
16
+ ## How to run
17
+
18
+ The script is located at `scripts/run-in-tmux`. Execute it using `uv run`:
19
+
20
+ ```bash
21
+ uv run scripts/run-in-tmux -c "npm run dev"
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ # Single command (JSON output)
28
+ uv run scripts/run-in-tmux -c "npm run dev" --json
29
+
30
+ # Multiple commands (each gets its own pane)
31
+ uv run scripts/run-in-tmux -c "npm run dev" -c "npm test" --json
32
+ ```
33
+
34
+ ## Options
35
+
36
+ | Flag | Description |
37
+ |------|-------------|
38
+ | `-c, --command` | Command to run (required, repeatable) |
39
+ | `--json` | Output JSON to stdout |
40
+ | `--plain` | Line-based plain text output |
41
+ | `-q, --quiet` | Suppress non-essential output |
42
+ | `-v, --verbose` | Show debug details |
43
+ | `--no-color` | Disable colored output |
44
+ | `--version` | Show version and exit |
45
+
46
+ ## Output
47
+
48
+ ### JSON format (`--json`)
49
+ ```bash
50
+ uv run scripts/run-in-tmux -c "echo hello" -c "echo world" --json
51
+ ```
52
+ ```json
53
+ {
54
+ "success": true,
55
+ "session_name": "picadillo-1f4f1afa",
56
+ "commands": ["echo hello", "echo world"],
57
+ "panes": 2,
58
+ "working_directory": "/Users/norman/code/picadillo",
59
+ "attach_command": "tmux attach -t picadillo-1f4f1afa",
60
+ "peek_commands": [
61
+ "tmux capture-pane -t picadillo-1f4f1afa.0 -p",
62
+ "tmux capture-pane -t picadillo-1f4f1afa.1 -p"
63
+ ],
64
+ "kill_command": "tmux kill-session -t picadillo-1f4f1afa"
65
+ }
66
+ ```
67
+
68
+ ## Session naming
69
+
70
+ Session names follow the pattern `<repo-slug>-<hash>`:
71
+ - `repo-slug`: First 8 characters of the git repository basename
72
+ - `hash`: 8-character MD5 hash of the repo path + commands
73
+
74
+ This ensures unique sessions for different commands in the same repo.
75
+
76
+ ## Requirements
77
+
78
+ - Python 3.10+
79
+ - tmux installed and running
80
+ - Must be in a git repository
81
+ - Dependencies: `typer`, `rich` (auto-installed via uv)
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = ["typer", "rich"]
5
+ # ///
6
+
7
+ """
8
+ run-in-tmux: Create a new tmux session for a project and run a command inside it.
9
+ """
10
+
11
+ __version__ = "0.3.0"
12
+
13
+ import hashlib
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import typer
19
+ from rich.console import Console
20
+
21
+ app = typer.Typer(add_completion=False)
22
+ console = Console()
23
+
24
+
25
+ def get_version() -> str:
26
+ return __version__
27
+
28
+
29
+ def get_git_root() -> Path | None:
30
+ """Get the root directory of the current Git repository."""
31
+ try:
32
+ result = subprocess.run(
33
+ ["git", "rev-parse", "--show-toplevel"],
34
+ capture_output=True,
35
+ text=True,
36
+ check=True,
37
+ )
38
+ return Path(result.stdout.strip())
39
+ except subprocess.CalledProcessError:
40
+ return None
41
+
42
+
43
+ def get_repo_name(path: Path) -> str:
44
+ """Get short slug from repo basename (first 16 chars, lowercase)."""
45
+ return path.name[:16].lower()
46
+
47
+
48
+ def generate_session_name(repo_path: Path, commands: list[str]) -> str:
49
+ """Generate unique session name: <slug>-<hash> where hash = MD5(repo_path + commands)."""
50
+ # Combine repo absolute path with all commands concatenated
51
+ combined = str(repo_path.absolute()) + "".join(commands)
52
+ # MD5 hash
53
+ md5_hash = hashlib.md5(combined.encode()).hexdigest()
54
+ # Truncate to 8 chars
55
+ short_hash = md5_hash[:8]
56
+ # Get repo slug (first 8 chars of basename)
57
+ slug = get_repo_name(repo_path)
58
+ return f"{slug}-{short_hash}"
59
+
60
+
61
+ def session_exists(session_name: str) -> bool:
62
+ """Check if a tmux session with the given name already exists."""
63
+ result = subprocess.run(
64
+ ["tmux", "has-session", "-t", session_name],
65
+ capture_output=True,
66
+ )
67
+ return result.returncode == 0
68
+
69
+
70
+ def create_tmux_session(
71
+ session_name: str, working_dir: Path, commands: list[str]
72
+ ) -> bool:
73
+ """Create a new tmux session with each command in its own split pane."""
74
+ try:
75
+ # Create session with first command in pane 0
76
+ # -s: session name
77
+ # -c: working directory
78
+ subprocess.run(
79
+ ["tmux", "new-session", "-d", "-s", session_name, "-c", str(working_dir)],
80
+ check=True,
81
+ )
82
+ # Send first command to pane 0
83
+ subprocess.run(
84
+ ["tmux", "send-keys", "-t", f"{session_name}.0", commands[0], "C-m"],
85
+ check=True,
86
+ )
87
+
88
+ # For each subsequent command, create a new pane and run the command
89
+ for i, cmd in enumerate(commands[1:], start=1):
90
+ # Split the window horizontally
91
+ subprocess.run(
92
+ ["tmux", "split-window", "-h", "-t", session_name],
93
+ check=True,
94
+ )
95
+ # Select the new pane and send the command
96
+ subprocess.run(
97
+ ["tmux", "send-keys", "-t", f"{session_name}.{i}", cmd, "C-m"],
98
+ check=True,
99
+ )
100
+
101
+ # Arrange panes in a tiled layout
102
+ subprocess.run(
103
+ ["tmux", "select-layout", "-t", session_name, "tiled"],
104
+ check=True,
105
+ )
106
+
107
+ return True
108
+ except subprocess.CalledProcessError as e:
109
+ return False
110
+
111
+
112
+ def output_human(session_name: str, commands: list[str], working_dir: Path) -> None:
113
+ """Output human-readable confirmation."""
114
+ console.print(f"[green]โœ“[/green] Session created: [bold]{session_name}[/bold]")
115
+ console.print(f" Commands ({len(commands)}):")
116
+ for i, cmd in enumerate(commands, 1):
117
+ console.print(f" {i}. {cmd}")
118
+ console.print(f" Working directory: {working_dir}")
119
+ console.print()
120
+ console.print("[bold]Attach to session:[/bold]")
121
+ console.print(f" tmux attach -t {session_name}")
122
+ console.print()
123
+ console.print("[bold]Peek pane output:[/bold]")
124
+ for i in range(len(commands)):
125
+ console.print(f" tmux capture-pane -t {session_name}.{i} -p")
126
+ console.print()
127
+ console.print("[bold]Kill session:[/bold]")
128
+ console.print(f" tmux kill-session -t {session_name}")
129
+
130
+
131
+ def output_json(session_name: str, commands: list[str], working_dir: Path) -> None:
132
+ """Output JSON for scripting."""
133
+ import json
134
+
135
+ output = {
136
+ "success": True,
137
+ "session_name": session_name,
138
+ "commands": commands,
139
+ "panes": len(commands),
140
+ "working_directory": str(working_dir),
141
+ "attach_command": f"tmux attach -t {session_name}",
142
+ "peek_commands": [
143
+ f"tmux capture-pane -t {session_name}.{i} -p" for i in range(len(commands))
144
+ ],
145
+ "kill_command": f"tmux kill-session -t {session_name}",
146
+ }
147
+ print(json.dumps(output))
148
+
149
+
150
+ def output_plain(session_name: str, commands: list[str], working_dir: Path) -> None:
151
+ """Output line-based plain text."""
152
+ print(session_name)
153
+ for cmd in commands:
154
+ print(cmd)
155
+ print(str(working_dir))
156
+ print(f"tmux attach -t {session_name}")
157
+ for i in range(len(commands)):
158
+ print(f"tmux capture-pane -t {session_name}.{i} -p")
159
+ print(f"tmux kill-session -t {session_name}")
160
+
161
+
162
+ def error_exit(message: str, json_output: bool = False) -> None:
163
+ """Print error to both stdout and stderr, exit with code 1."""
164
+ if json_output:
165
+ import json
166
+
167
+ print(json.dumps({"success": False, "error": message}), file=sys.stderr)
168
+ else:
169
+ # Use styled output for stderr - use Console with stderr, force color
170
+ err_console = Console(
171
+ stderr=True, force_terminal=True, no_color=console.no_color
172
+ )
173
+ err_console.print(f"[red]Error:[/red] {message}")
174
+ raise typer.Exit(code=1)
175
+
176
+
177
+ @app.command()
178
+ def main(
179
+ command: list[str] | None = typer.Option(
180
+ None,
181
+ "--command",
182
+ "-c",
183
+ help="Command to run inside tmux session. Repeat for multiple sequential commands.",
184
+ ),
185
+ json: bool = typer.Option(False, "--json", help="Output JSON to stdout"),
186
+ plain: bool = typer.Option(False, "--plain", help="Line-based plain text output"),
187
+ quiet: bool = typer.Option(
188
+ False, "-q", "--quiet", help="Suppress non-essential output"
189
+ ),
190
+ verbose: bool = typer.Option(False, "-v", "--verbose", help="Show debug details"),
191
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
192
+ version: bool = typer.Option(False, "--version", help="Show version and exit"),
193
+ ):
194
+ """
195
+ Create a new tmux session for a project and run commands in separate panes.
196
+
197
+ Each command runs in its own split pane of the first window.
198
+
199
+ Examples:
200
+
201
+ run-in-tmux -c "npm run dev"
202
+ run-in-tmux -c "npm run dev" -c "npm test"
203
+ run-in-tmux -c "echo one" -c "echo two" -c "echo three"
204
+ """
205
+ # Handle version flag first
206
+ if version:
207
+ print(get_version())
208
+ raise typer.Exit(code=0)
209
+
210
+ # Handle no-color
211
+ if no_color:
212
+ console.no_color = True
213
+
214
+ # Validate required --command
215
+ if command is None or len(command) == 0:
216
+ error_exit('--command is required. Usage: run-in-tmux -c "npm test"', False)
217
+
218
+ # Check if in a git repo
219
+ git_root = get_git_root()
220
+ if git_root is None:
221
+ error_exit("not in a git repository. Run from a project root.", json)
222
+
223
+ if verbose:
224
+ console.print(f"[dim]Git root: {git_root}[/dim]")
225
+
226
+ # Generate session name
227
+ session_name = generate_session_name(git_root, command)
228
+
229
+ if verbose:
230
+ console.print(f"[dim]Session name: {session_name}[/dim]")
231
+
232
+ # Check if session already exists
233
+ if session_exists(session_name):
234
+ error_exit(
235
+ f"session {session_name} already exists.\nUse tmux attach -t {session_name} to reconnect.",
236
+ json,
237
+ )
238
+
239
+ # Create the tmux session
240
+ success = create_tmux_session(session_name, git_root, command)
241
+ if not success:
242
+ error_exit("failed to create tmux session.", json)
243
+
244
+ # Output based on flags
245
+ if json:
246
+ output_json(session_name, command, git_root)
247
+ elif plain:
248
+ output_plain(session_name, command, git_root)
249
+ else:
250
+ output_human(session_name, command, git_root)
251
+
252
+ raise typer.Exit(code=0)
253
+
254
+
255
+ if __name__ == "__main__":
256
+ app()
257
+
258
+ # vim: ft=python