Automatically Running Project Startup Commands in iTerm2

Getting your development environment set up with a single command in iTerm2 so you can start working on your project quickly.

Joe Tannenbaum

This post is part of a series of useful commands I've built for myself as part of my day-to-day workflow. I hope you find them useful!

Ok, the workday has started. Time to jump into a project. Let's work on Blip. Fire up Alfred, punch in term blip and we've jumped to the project directory in the terminal.

My Alfred workflow for jumping to projects quickly in iTerm2
My Alfred workflow for jumping to projects quickly in iTerm2

Let's get our environment set up. We need to:

  • Open the project in VS Code

  • Start compiling the assets

  • Start Laravel's local scheduler

  • Start Laravel's local queue listener

  • Start listening for Stripe webhooks using the Stripe CLI

  • Open up Ray and HELO (side note: if you are not using Ray and HELO I cannot recommend enough, game changers for local development )

Phew. I decided years ago that a) I don't want to type all of these commands every time and b) I still wanted them to run as if I had actually typed them. Thus cs (custom start) was born.

iTerm2 Python Integration

Thankfully, iTerm2 comes with a pretty solid Python API. This allows us to control iTerm2 via a Python script, which is exactly what we're looking to do.

Writing the Command

Let's get into it. As a refresher, in case you haven't read the previous post, we're using Laravel Zero and the app name is util. So let's scaffold a command:

1php util make:command CustomStartRun

The idea here is that we are going to run a command (cs) and the script will:

  • Check for the existence of a script in the directory

  • If it finds one, it runs it

  • If it doesn't find one, it offers to create it

The command itself is going to be very simple (honestly, we probably don't even need Laravel Zero for it but it's my comfort zone so here we are). Just like in the last post, we're going to add to our global git ignore so we don't have to ignore it repo-by-repo.

Here's the command in its entirety:

3namespace App\Commands;
5use LaravelZero\Framework\Commands\Command;
7class CustomStartRun extends Command
9 /**
10 * The signature of the command.
11 *
12 * @var string
13 */
14 protected $signature = 'custom-start:run';
16 /**
17 * The description of the command.
18 *
19 * @var string
20 */
21 protected $description = 'Run a custom start script for the project';
23 /**
24 * Execute the console command.
25 *
26 * @return mixed
27 */
28 public function handle()
29 {
30 $dir = exec('pwd');
31 $path = $dir . '/';
33 if (!$this->createScriptFile($path)) {
34 return;
35 }
37 exec('./');
38 }
40 protected function createScriptFile($path): bool
41 {
42 if (file_exists($path)) {
43 return true;
44 }
46 $this->error("Script missing: {$path}");
48 if (!$this->confirm('Create script file?', true)) {
49 return false;
50 }
52 file_put_contents(
53 $path,
54 file_get_contents(base_path('templates/'))
55 );
57 exec("chmod +x {$path}");
59 $this->info("File created: {$path}");
61 // Still return false, we just created the file and we don't want to execute it right away
62 return false;
63 }

Looks good! Let's see what the Python script looks like:

Note: I write in a bunch of different languages, but Python is generally not one of them. So this might be terrible Python code, but it's worked for me for years so 🤷‍♂️. Open to suggestions.

1#!/usr/bin/env python3
3import asyncio
4import iterm2
6async def run_command_in_new_tab(window, title, command, closeWhenDone=False, newLine=True):
7 tab = await window.async_create_tab()
8 await tab.async_activate()
10 if (title):
11 await tab.async_set_title(title)
13 append = '\n' if newLine is True else ''
14 final_command = ' && '.join(command) if isinstance(
15 command, list) else command
17 await tab.current_session.async_send_text(final_command + append)
19 if closeWhenDone:
20 # Give the command a moment to execute before closing the tab
21 await asyncio.sleep(2)
22 await tab.async_close()
24async def main(connection):
25 app = await iterm2.async_get_app(connection)
27 # Foreground the app
28 await app.async_activate()
30 window = app.current_terminal_window
32 await window.current_tab.current_session.async_send_text('code .\n')
33 # await run_command_in_new_tab(window, 'Vite', 'yarn dev')
34 # await run_command_in_new_tab(window, 'Cron', 'php artisan schedule:work')
35 # await run_command_in_new_tab(window, 'Queue', 'php artisan queue:listen')
36 # await run_command_in_new_tab(window, 'Open Apps', 'open -a', True)
37 # await run_command_in_new_tab(window, 'Stripe Listener', 'stripe listen --forward-to DOMAIN/stripe/webhook')
39# Passing True for the second parameter means keep trying to
40# connect until the app launches.
41iterm2.run_until_complete(main, True)

I've preloaded it with some commented-out commands that I generally run for projects.

Some nice features here:

  • Each tab that is opened has a title so it's very clear what is running.

  • If it runs a command (such as opening Ray and HELO) that doesn't need to keep a tab open afterward, we can specify that the tab should close after the command is run.

  • The script simulates actually typing into the terminal, so if you need to restart a process, just switch over to that tab and restart it. The command is right there in iTerm2 already.

Finally, let's alias the command as cs in my .zshrc:

2alias cs="$helperPHPPath ~/Dev/util-zero/util custom-start:run"

The Result

That's it! Super simple. For Blip, these are the commands I run to set up the dev environment:

1await window.current_tab.current_session.async_send_text('code .\n')
2await window.current_tab.current_session.async_send_text('open https://blip.test\n')
3await run_command_in_new_tab(window, 'Vite', 'yarn dev')
4await run_command_in_new_tab(window, 'Cron', 'php artisan schedule:work')
5await run_command_in_new_tab(window, 'Queue', 'php artisan queue:listen')
6await run_command_in_new_tab(window, 'Stripe Listener', 'stripe listen --forward-to https://blip.test/stripe/webhook')
7await run_command_in_new_tab(window, 'Open Apps', 'open -a && open -a', True)

Let's see it in action:

Have any ideas for how I could improve this? Does this spark any ideas of your own? I'm all ears! Let me know.

Thanks for following along! Stay tuned for more helpful commands as I continue the series.

(Excellent) syntax highlighting provided by Torchlight