aircon 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7a82194933fb47bffb6daacf310c4f244ab1d9a25db2f9b32ec25edd578c4a06
4
+ data.tar.gz: 0222cec49d295996c4066d56518ee84fff8747c8189713f4581a17e103ac8c61
5
+ SHA512:
6
+ metadata.gz: 937ab4b996d225743372ed6d5c25fe319c2305dfd84bca24b95671465ae1846ab20043f2ad7e442096e09cb060840e2a2bd948b5b723932469bb99f744c85dd4
7
+ data.tar.gz: 7623f3fd28dd5adffbd85908447c1d5e640a952a9eb2c27d0c31f8de7f2407d76a22762f34f50b0f3f4aab3fe750b5e34e33cc69fdb29575804bee222aa02629
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Philip Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # Aircon
2
+
3
+ No more worktrees. Aircon gives every feature branch its own isolated Docker container pre-loaded with Claude Code, your credentials, and a running shell — so you can work on multiple branches in parallel without them stepping on each other. Dependencies like databases are isolated, so a db migration in one container does not affect the other.
4
+
5
+ Each container gets:
6
+ - Your Claude Code credentials and settings injected at startup (no Dockerfile changes needed)
7
+ - Claude Code installed automatically if not already in the image
8
+ - Your GitHub token set for authenticated `git` and `gh` operations
9
+ - A git branch checked out and ready to go
10
+ - An optional project-specific init script that runs after the container is up
11
+
12
+ When you close the last shell session, the container and its volumes are automatically torn down.
13
+
14
+ ---
15
+
16
+ ## Prerequisites
17
+
18
+ - Ruby >= 3.3.0
19
+ - Docker and Docker Compose
20
+ - Claude Code installed on your host machine (aircon copies credentials from it)
21
+ - VS Code with the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension (only needed for `aircon vscode`)
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ gem install aircon
29
+ ```
30
+
31
+ Or add to your Gemfile:
32
+
33
+ ```ruby
34
+ gem "aircon"
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Getting Started
40
+
41
+ **1. Initialize aircon in your project:**
42
+
43
+ ```bash
44
+ cd your-project
45
+ aircon init
46
+ ```
47
+
48
+ This creates four files under `.aircon/`:
49
+
50
+ | File | Purpose |
51
+ |------|---------|
52
+ | `aircon.yml` | Main config (tokens, paths, user settings) |
53
+ | `aircon_init.sh` | Script run inside the container after setup |
54
+ | `Dockerfile` | Base image for your dev container, change it or use your own |
55
+ | `docker-compose.yml` | Compose config wired to the Dockerfile, change it or use your own |
56
+
57
+ Existing files are never overwritten, so `aircon init` is safe to re-run.
58
+
59
+ **2. Configure your tokens:**
60
+
61
+ Edit `.aircon/aircon.yml` and set at minimum:
62
+
63
+ ```yaml
64
+ gh_token: <%= ENV['GITHUB_TOKEN'] %>
65
+ ```
66
+
67
+ **3. Start a container:**
68
+
69
+ ```bash
70
+ aircon up my-feature
71
+ ```
72
+
73
+ This builds the image, injects your Claude credentials, checks out a branch named `my-feature`, runs your init script, and drops you into a shell inside the container. The container and dependencies set in the docker-compose file will all be running under the `my-feature` docker project, fully isolating it from other containers.
74
+
75
+ ---
76
+
77
+ ## Commands
78
+
79
+ ### `aircon up NAME [PORT]`
80
+
81
+ Start or attach to a dev container.
82
+
83
+ ```bash
84
+ aircon up my-feature # start on default port 3001
85
+ aircon up my-feature 3005 # start on port 3005
86
+ aircon up my-feature -b feat/auth # use a different git branch than NAME
87
+ aircon up my-feature -d # start detached (no interactive shell)
88
+ ```
89
+
90
+ **What `aircon up` does, step by step:**
91
+
92
+ 1. Looks for an existing container named `NAME-SERVICE-1` (e.g. `my-feature-app-1`).
93
+ - If found → attaches a new `bash` session to it and skips to step 12.
94
+ 2. Warns if `gh_token` is not configured.
95
+ 3. Runs `docker compose up -d --build` with `HOST_PORT`, `AIRCON_APP_NAME`, `AIRCON_CONTAINER_USER`, and `AIRCON_WORKSPACE_PATH` injected as environment variables.
96
+ 4. Copies `~/.claude.json` and `~/.claude/` from your host into the container via `docker cp` (no `COPY` lines needed in your Dockerfile). Host home paths inside those files are rewritten to match the container home directory.
97
+ 5. Installs Claude Code inside the container if not already present (`curl -fsSL https://claude.ai/install.sh | bash`).
98
+ 6. Adds `~/.local/bin` to `PATH` in `/etc/bash.bashrc` so `claude` is available in all sessions.
99
+ 7. Writes `GH_TOKEN` and `GITHUB_PERSONAL_ACCESS_TOKEN` to `/etc/bash.bashrc` (if `gh_token` is set).
100
+ 8. Writes `CLAUDE_CODE_OAUTH_TOKEN` to `/etc/bash.bashrc` (if configured).
101
+ 9. Sets `git config user.email` and `git config user.name` globally inside the container.
102
+ 10. Configures `git` to authenticate GitHub URLs with your token (covers both `https://github.com/` and `git@github.com:`).
103
+ 11. Checks out the branch:
104
+ - If the branch exists on `origin` → fetches and checks it out.
105
+ - Otherwise → creates a new branch from `origin/main`.
106
+ 12. Runs the `init_script` (`.aircon/aircon_init.sh` by default) inside the container via `bash -l`, if the file exists.
107
+ 13. Attaches an interactive `bash` session.
108
+ 14. When the last `bash` session exits → runs `docker compose down -v --remove-orphans` and `docker image prune -f`.
109
+
110
+ **Options:**
111
+
112
+ | Option | Alias | Description |
113
+ |--------|-------|-------------|
114
+ | `--branch BRANCH` | `-b` | Git branch to check out (defaults to NAME) |
115
+ | `--detach` | `-d` | Start without attaching an interactive session |
116
+
117
+ ---
118
+
119
+ ### `aircon down NAME`
120
+
121
+ Tear down the container and volumes for a project.
122
+
123
+ ```bash
124
+ aircon down my-feature
125
+ ```
126
+
127
+ Runs `docker compose down -v --remove-orphans` and prunes unused images. Use this to clean up manually if you need to reset state without waiting for a session to end.
128
+
129
+ ---
130
+
131
+ ### `aircon vscode NAME`
132
+
133
+ Attach VS Code to a running container.
134
+
135
+ ```bash
136
+ aircon vscode my-feature
137
+ ```
138
+
139
+ The container must already be running (`aircon up` first). Opens VS Code connected to the container via the Dev Containers extension, with the workspace set to `workspace_path`.
140
+
141
+ ---
142
+
143
+ ### `aircon init`
144
+
145
+ Generate the `.aircon/` config files in the current directory.
146
+
147
+ ```bash
148
+ aircon init
149
+ ```
150
+
151
+ Creates `aircon.yml`, `aircon_init.sh`, `Dockerfile`, and `docker-compose.yml` under `.aircon/`. Safe to re-run — existing files are not overwritten.
152
+
153
+ ---
154
+
155
+ ### `aircon version`
156
+
157
+ Print the installed version.
158
+
159
+ ```bash
160
+ aircon version
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Configuration
166
+
167
+ Config is loaded from `.aircon/aircon.yml` in your project root. All keys are optional. ERB is supported, so you can pull in environment variables.
168
+
169
+ ```yaml
170
+ # Docker Compose file to use
171
+ compose_file: .aircon/docker-compose.yml
172
+
173
+ # Application name — used for DB credentials in the default Compose template
174
+ # Defaults to the basename of the current directory
175
+ app_name: my-app
176
+
177
+ # GitHub personal access token — authenticates git and gh inside the container
178
+ gh_token: <%= ENV['GITHUB_TOKEN'] %>
179
+
180
+ # Claude Code OAuth token — set as CLAUDE_CODE_OAUTH_TOKEN inside the container
181
+ claude_code_oauth_token: <%= ENV['CLAUDE_CODE_OAUTH_TOKEN'] %>
182
+
183
+ # Workspace folder path inside the container
184
+ workspace_path: /my-app
185
+
186
+ # Path to your Claude config file on the host
187
+ claude_config_path: ~/.claude.json
188
+
189
+ # Path to your Claude directory on the host
190
+ claude_dir_path: ~/.claude
191
+
192
+ # Docker Compose service name
193
+ service: app
194
+
195
+ # Git identity inside the container
196
+ git_email: claude_docker@localhost.com
197
+ git_name: Claude Docker
198
+
199
+ # Non-root user inside the container (determines home directory)
200
+ container_user: appuser
201
+
202
+ # Script to run inside the container after setup (path relative to this file)
203
+ init_script: .aircon/aircon_init.sh
204
+ ```
205
+
206
+ ### Configuration Reference
207
+
208
+ | Key | Default | Description |
209
+ |-----|---------|-------------|
210
+ | `compose_file` | `.aircon/docker-compose.yml` | Docker Compose file to use |
211
+ | `app_name` | basename of cwd | App name passed to Compose as `AIRCON_APP_NAME` |
212
+ | `gh_token` | `nil` | GitHub token; sets `GH_TOKEN` and `GITHUB_PERSONAL_ACCESS_TOKEN` in the container |
213
+ | `claude_code_oauth_token` | `nil` | Claude Code OAuth token; sets `CLAUDE_CODE_OAUTH_TOKEN` in the container |
214
+ | `workspace_path` | `/workspace` | Workspace folder path inside the container |
215
+ | `claude_config_path` | `~/.claude.json` | Host path to `claude.json` |
216
+ | `claude_dir_path` | `~/.claude` | Host path to `.claude/` directory |
217
+ | `service` | `app` | Docker Compose service name for the main container |
218
+ | `git_email` | `claude_docker@localhost.com` | Git author email inside the container |
219
+ | `git_name` | `Claude Docker` | Git author name inside the container |
220
+ | `container_user` | `appuser` | Non-root user inside the container |
221
+ | `init_script` | `.aircon/aircon_init.sh` | Script run after setup; has access to `GH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, etc. |
222
+
223
+ ---
224
+
225
+ ## Notes
226
+
227
+ - SSH keys are not managed by aircon — handle them in your `init_script` if needed.
228
+ - Your `Dockerfile` does not need `COPY` instructions for Claude settings; aircon injects them at runtime via `docker cp`.
229
+ - The Docker Compose project name is set to `NAME` (the argument to `aircon up`), so the container will be named `NAME-SERVICE-1` (e.g. `my-feature-app-1`). This is how aircon identifies containers across commands.
230
+
231
+ ---
232
+
233
+ ## Releasing to RubyGems
234
+
235
+ 1. Bump the version in `lib/aircon/version.rb`.
236
+ 2. Build and push:
237
+
238
+ ```bash
239
+ gem build aircon.gemspec
240
+ gem push aircon-<version>.gem
241
+ ```
242
+
243
+ You'll be prompted for your RubyGems credentials on first push. Subsequent pushes use the stored API key at `~/.gem/credentials`.
244
+
245
+ ---
246
+
247
+ ## License
248
+
249
+ MIT — see [LICENSE.txt](LICENSE.txt).
data/exe/aircon ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "aircon"
6
+
7
+ Aircon::CLI.start(ARGV)
data/lib/aircon/cli.rb ADDED
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "thor"
5
+
6
+ module Aircon
7
+ class CLI < Thor
8
+ def self.exit_on_failure?
9
+ true
10
+ end
11
+
12
+ desc "up NAME [PORT]", "Start or attach to a dev container for the given project"
13
+ method_option :detach, type: :boolean, default: false, aliases: "-d",
14
+ desc: "Start container without attaching an interactive session"
15
+ method_option :branch, type: :string, aliases: "-b",
16
+ desc: "Git branch to check out (defaults to NAME)"
17
+ def up(name, port = "3001")
18
+ config = Configuration.new
19
+ branch = options[:branch] || name
20
+ Commands::Up.new(config: config).call(name, branch: branch, port: port, detach: options[:detach])
21
+ end
22
+
23
+ desc "down NAME", "Tear down the container and volumes for the given project"
24
+ def down(name)
25
+ config = Configuration.new
26
+ Commands::Down.new(config: config).call(name)
27
+ end
28
+
29
+ desc "vscode NAME", "Attach VS Code to a running container for the given project"
30
+ def vscode(name)
31
+ config = Configuration.new
32
+ Commands::Vscode.new(config: config).call(name)
33
+ end
34
+
35
+ desc "init", "Create a sample .aircon/aircon.yml, Dockerfile, and docker-compose.yml in the current directory"
36
+ def init
37
+ FileUtils.mkdir_p(File.join(Dir.pwd, ".aircon"))
38
+
39
+ {
40
+ "aircon.yml" => SAMPLE_CONFIG,
41
+ "aircon_init.sh" => INIT_SCRIPT_TEMPLATE,
42
+ "Dockerfile" => DOCKERFILE_TEMPLATE,
43
+ "docker-compose.yml" => COMPOSE_TEMPLATE
44
+ }.each do |filename, content|
45
+ path = File.join(Dir.pwd, ".aircon", filename)
46
+ if File.exist?(path)
47
+ puts "Skipped .aircon/#{filename} (already exists)"
48
+ else
49
+ File.write(path, content)
50
+ puts "Created .aircon/#{filename}"
51
+ end
52
+ end
53
+ end
54
+
55
+ desc "version", "Show aircon version"
56
+ def version
57
+ puts "aircon #{VERSION}"
58
+ end
59
+
60
+ SAMPLE_CONFIG = <<~'YAML'
61
+ # Aircon configuration — ERB is supported (e.g. <%= ENV['GITHUB_TOKEN'] %>)
62
+ # See: https://github.com/creativesorcery/aircon
63
+
64
+ # Docker Compose file to use (default: .aircon/docker-compose.yml)
65
+ # compose_file: .aircon/docker-compose.yml
66
+
67
+ # Application name used for database credentials etc. (default: directory basename)
68
+ # app_name: myapp
69
+
70
+ # GitHub personal access token (supports ERB)
71
+ # gh_token: <%= ENV['GITHUB_TOKEN'] %>
72
+
73
+ # Claude Code OAuth token (supports ERB)
74
+ # claude_code_oauth_token: <%= ENV['CLAUDE_CODE_OAUTH_TOKEN'] %>
75
+
76
+ # Workspace folder path inside the container
77
+ # workspace_path: /myproject
78
+
79
+ # Path to host's Claude config file
80
+ # claude_config_path: ~/.claude.json
81
+
82
+ # Path to host's Claude directory
83
+ # claude_dir_path: ~/.claude
84
+
85
+ # Docker Compose service name for the main container
86
+ # service: app
87
+
88
+ # Git author identity inside the container
89
+ # git_email: claude_docker@localhost.com
90
+ # git_name: Claude Docker
91
+
92
+ # Non-root user inside the container
93
+ # container_user: appuser
94
+
95
+ # Script to run inside the container after setup (path relative to project root)
96
+ # Defaults to .aircon/aircon_init.sh — edit that file to add your setup steps.
97
+ # init_script: .aircon/aircon_init.sh
98
+ YAML
99
+
100
+ INIT_SCRIPT_TEMPLATE = <<~'BASH'
101
+ #!/bin/bash
102
+ # .aircon/aircon_init.sh
103
+ #
104
+ # This script runs inside the container after aircon completes its setup.
105
+ # It is invoked as a login shell (bash -l), so environment variables
106
+ # configured by aircon are available:
107
+ #
108
+ # GH_TOKEN / GITHUB_PERSONAL_ACCESS_TOKEN — GitHub personal access token
109
+ # CLAUDE_CODE_OAUTH_TOKEN — Claude Code OAuth token
110
+ # PATH — includes ~/.local/bin (claude, gh, etc.)
111
+ #
112
+ # The working directory is the repository root inside the container.
113
+ #
114
+ # Examples:
115
+ # npm install
116
+ # bundle install
117
+ # cp .env.example .env
118
+ BASH
119
+
120
+ DOCKERFILE_TEMPLATE = <<~'DOCKERFILE'
121
+ FROM ruby:4.0.1
122
+
123
+ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
124
+
125
+ # Add GitHub CLI repository
126
+ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
127
+ && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
128
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null
129
+
130
+ # Install dependencies
131
+ RUN apt-get update -qq && \
132
+ apt-get install -y --no-install-recommends \
133
+ bash \
134
+ build-essential \
135
+ git \
136
+ libpq-dev \
137
+ postgresql-client \
138
+ curl \
139
+ libvips \
140
+ bubblewrap \
141
+ socat \
142
+ nodejs \
143
+ gh \
144
+ && rm -rf /var/lib/apt/lists/*
145
+
146
+ # Make /bin/sh point to bash instead of dash (required for devcontainer features)
147
+ RUN ln -sf /bin/bash /bin/sh
148
+
149
+ # Ensure bash is the default shell for RUN commands
150
+ SHELL ["/bin/bash", "-c"]
151
+
152
+ # Create a non-root user
153
+ ARG USERNAME=appuser
154
+ ARG USER_UID=1000
155
+ ARG USER_GID=$USER_UID
156
+ ARG WORKSPACE_PATH=/workspace
157
+
158
+ RUN groupadd --gid $USER_GID $USERNAME \
159
+ && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
160
+ && apt-get update \
161
+ && apt-get install -y sudo \
162
+ && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
163
+ && chmod 0440 /etc/sudoers.d/$USERNAME \
164
+ && rm -rf /var/lib/apt/lists/*
165
+
166
+ # Give the container user write access to the gem directory
167
+ RUN chown -R $USER_UID:$USER_GID /usr/local/bundle
168
+
169
+ RUN npm install -g @anthropic-ai/sandbox-runtime
170
+ RUN npm install -g yarn
171
+ RUN npm install -g playwright@1.58.1
172
+ RUN playwright install --with-deps chromium
173
+
174
+ COPY --chown=$USERNAME:$USERNAME . $WORKSPACE_PATH
175
+
176
+ USER $USERNAME
177
+
178
+ RUN playwright install chromium
179
+
180
+ WORKDIR $WORKSPACE_PATH
181
+
182
+ RUN bundle install
183
+ DOCKERFILE
184
+
185
+ COMPOSE_TEMPLATE = <<~'YAML'
186
+ services:
187
+ app:
188
+ build:
189
+ context: ..
190
+ dockerfile: .aircon/Dockerfile
191
+ args:
192
+ USERNAME: ${AIRCON_CONTAINER_USER:-appuser}
193
+ WORKSPACE_PATH: ${AIRCON_WORKSPACE_PATH:-/workspace}
194
+ ports:
195
+ - "${HOST_PORT:-3001}:3000"
196
+ command: sleep infinity
197
+ environment:
198
+ DATABASE_HOST: db
199
+ DATABASE_USER: ${AIRCON_APP_NAME:-app}
200
+ RAILS_ENV: development
201
+ RAILS_BIND: 0.0.0.0
202
+ depends_on:
203
+ db:
204
+ condition: service_started
205
+
206
+ db:
207
+ image: postgres:18
208
+ restart: unless-stopped
209
+ environment:
210
+ POSTGRES_USER: ${AIRCON_APP_NAME:-app}
211
+ POSTGRES_HOST_AUTH_METHOD: trust
212
+ POSTGRES_DB: ${AIRCON_APP_NAME:-app}_development
213
+ YAML
214
+ end
215
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircon
4
+ module Commands
5
+ class Down
6
+ def initialize(config:)
7
+ @config = config
8
+ end
9
+
10
+ def call(name)
11
+ puts "Tearing down containers for '#{name}'..."
12
+ system("docker", "compose", "-p", name, "down", "-v", "--remove-orphans")
13
+ system("docker", "image", "prune", "-f")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "tmpdir"
6
+
7
+ module Aircon
8
+ module Commands
9
+ class Up
10
+ def initialize(config:)
11
+ @config = config
12
+ end
13
+
14
+ def call(name, branch:, port: "3001", detach: false)
15
+ container = Docker.find_container(project: name, service: @config.service)
16
+
17
+ if container
18
+ attach_existing(container, name, detach: detach)
19
+ else
20
+ start_new(name, branch, port, detach: detach)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def attach_existing(container, name, detach: false)
27
+ if detach
28
+ puts "Container for '#{name}' is already running: #{container}"
29
+ return
30
+ end
31
+
32
+ puts "Attaching to existing container for '#{name}'..."
33
+ system("docker", "exec", "-it", container, "bash")
34
+ cleanup_if_last(container, name)
35
+ end
36
+
37
+ def start_new(name, branch, port, detach: false)
38
+ if @config.gh_token.nil? || @config.gh_token.to_s.empty?
39
+ warn "Warning: gh_token not configured. GitHub CLI (gh) will not be authenticated."
40
+ warn " Set gh_token in .aircon.yml if you want to use 'gh' commands."
41
+ end
42
+
43
+ env = {
44
+ "HOST_PORT" => port.to_s,
45
+ "AIRCON_APP_NAME" => @config.app_name,
46
+ "AIRCON_CONTAINER_USER" => @config.container_user,
47
+ "AIRCON_WORKSPACE_PATH" => @config.workspace_path
48
+ }
49
+ system(env, "docker", "compose",
50
+ "-f", @config.compose_file,
51
+ "-p", name,
52
+ "up", "-d", "--build")
53
+
54
+ container = Docker.find_container(project: name, service: @config.service)
55
+ abort "Error: Could not find container after starting services." unless container
56
+
57
+ inject_claude_settings(container)
58
+ setup_container(container, branch)
59
+ run_init_script(container)
60
+
61
+ if detach
62
+ puts "Container started: #{container}"
63
+ return
64
+ end
65
+
66
+ system("docker", "exec", "-it", container, "bash")
67
+ cleanup_if_last(container, name)
68
+ end
69
+
70
+ def inject_claude_settings(container)
71
+ Dir.mktmpdir("aircon_claude_settings") do |staging|
72
+ claude_config = File.expand_path(@config.claude_config_path)
73
+ claude_dir = File.expand_path(@config.claude_dir_path)
74
+
75
+ FileUtils.cp(claude_config, File.join(staging, ".claude.json")) if File.exist?(claude_config)
76
+
77
+ if File.directory?(claude_dir)
78
+ FileUtils.cp_r(claude_dir, File.join(staging, ".claude"))
79
+ else
80
+ FileUtils.mkdir_p(File.join(staging, ".claude"))
81
+ end
82
+
83
+ home = @config.container_home
84
+ rewrite_paths(staging, home)
85
+ user = @config.container_user
86
+ system("docker", "cp", "#{File.join(staging, '.claude')}/.", "#{container}:#{home}/.claude")
87
+ system("docker", "cp", File.join(staging, ".claude.json"), "#{container}:#{home}/.claude.json")
88
+ system("docker", "exec", "-u", "root", container,
89
+ "bash", "-c", "chmod -R u+rwX #{home}/.claude #{home}/.claude.json && " \
90
+ "chown -R #{user}:#{user} #{home}/.claude #{home}/.claude.json")
91
+ end
92
+ end
93
+
94
+ def rewrite_paths(staging, container_home)
95
+ host_home = File.expand_path("~")
96
+
97
+ Dir.glob(File.join(staging, "**", "*"), File::FNM_DOTMATCH).each do |path|
98
+ next unless File.file?(path)
99
+ next unless File.readable?(path)
100
+
101
+ content = File.binread(path)
102
+ next unless content.valid_encoding?
103
+ next unless content.include?(host_home)
104
+
105
+ File.write(path, content.gsub(host_home, container_home))
106
+ end
107
+ end
108
+
109
+ def setup_container(container, branch)
110
+ home = @config.container_home
111
+
112
+ # Install Claude Code if not already present, and ensure it's on PATH for all shells
113
+ system("docker", "exec", container, "bash", "-c",
114
+ "command -v claude >/dev/null 2>&1 || curl -fsSL https://claude.ai/install.sh | bash")
115
+ system("docker", "exec", "-u", "root", container, "bash", "-c",
116
+ "grep -qF '#{home}/.local/bin' /etc/bash.bashrc 2>/dev/null || " \
117
+ "echo 'export PATH=\"#{home}/.local/bin:$PATH\"' >> /etc/bash.bashrc")
118
+
119
+ if @config.gh_token && !@config.gh_token.to_s.empty?
120
+ system("docker", "exec", "-u", "root", container, "bash", "-c",
121
+ "grep -qF 'export GH_TOKEN=' /etc/bash.bashrc 2>/dev/null || " \
122
+ "echo 'export GH_TOKEN=\"#{@config.gh_token}\"' >> /etc/bash.bashrc")
123
+ system("docker", "exec", "-u", "root", container, "bash", "-c",
124
+ "grep -qF 'export GITHUB_PERSONAL_ACCESS_TOKEN=' /etc/bash.bashrc 2>/dev/null || " \
125
+ "echo 'export GITHUB_PERSONAL_ACCESS_TOKEN=\"#{@config.gh_token}\"' >> /etc/bash.bashrc")
126
+ end
127
+
128
+ if @config.claude_code_oauth_token && !@config.claude_code_oauth_token.to_s.empty?
129
+ system("docker", "exec", "-u", "root", container, "bash", "-c",
130
+ "grep -qF 'export CLAUDE_CODE_OAUTH_TOKEN=' /etc/bash.bashrc 2>/dev/null || " \
131
+ "echo 'export CLAUDE_CODE_OAUTH_TOKEN=\"#{@config.claude_code_oauth_token}\"' >> /etc/bash.bashrc")
132
+ end
133
+
134
+ # Configure git and create branch
135
+ system("docker", "exec", container, "git", "config", "--global", "user.email", @config.git_email)
136
+ system("docker", "exec", container, "git", "config", "--global", "user.name", @config.git_name)
137
+ # Configure git authentication for GitHub using the personal access token
138
+ if @config.gh_token && !@config.gh_token.to_s.empty?
139
+ authed = "https://x-access-token:#{@config.gh_token}@github.com/"
140
+ system("docker", "exec", container, "git", "config", "--global",
141
+ "url.#{authed}.insteadOf", "https://github.com/")
142
+ system("docker", "exec", container, "git", "config", "--global",
143
+ "url.#{authed}.insteadOf", "git@github.com:")
144
+ end
145
+ # Check if branch exists on remote; if so, check it out, otherwise create new
146
+ _, status = Open3.capture2("docker", "exec", container, "git", "ls-remote", "--heads", "origin", branch)
147
+ if status.success? && !_.strip.empty?
148
+ system("docker", "exec", container, "git", "fetch", "origin", branch)
149
+ system("docker", "exec", container, "git", "checkout", "-b", branch, "origin/#{branch}")
150
+ else
151
+ system("docker", "exec", container, "git", "fetch", "origin", "main")
152
+ system("docker", "exec", container, "git", "checkout", "-b", branch, "origin/main")
153
+ end
154
+
155
+ # If you have the official anthropic marketplace plugin installed, it will always make a call to the anthropic github repo on claude startup. It uses SSH, but it should be https for universal compatibility since its a public repository.
156
+ system("docker", "exec", container, "git", "config", "--global", "url.\"https://github.com/anthropics/\".insteadOf", "ssh://git@github.com/anthropics/")
157
+ end
158
+
159
+ def run_init_script(container)
160
+ return unless @config.init_script && !@config.init_script.to_s.empty?
161
+
162
+ script_path = File.expand_path(@config.init_script)
163
+ unless File.exist?(script_path)
164
+ warn "Warning: init_script '#{@config.init_script}' not found, skipping."
165
+ return
166
+ end
167
+
168
+ home = @config.container_home
169
+ remote_script = "#{home}/.aircon_init.sh"
170
+ system("docker", "cp", script_path, "#{container}:#{remote_script}")
171
+ system("docker", "exec", container, "bash", "-l", remote_script)
172
+ end
173
+
174
+ def cleanup_if_last(container, name)
175
+ out, = Open3.capture2("docker", "exec", container, "pgrep", "-x", "bash")
176
+ remaining = out.strip.lines.size
177
+
178
+ return unless remaining == 0
179
+
180
+ puts "Last session ended. Cleaning up..."
181
+ system("docker", "compose", "-p", name, "down", "-v", "--remove-orphans")
182
+ system("docker", "image", "prune", "-f")
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircon
4
+ module Commands
5
+ class Vscode
6
+ def initialize(config:)
7
+ @config = config
8
+ end
9
+
10
+ def call(name)
11
+ container = Docker.find_container(project: name, service: @config.service)
12
+
13
+ unless container
14
+ abort "Error: No running container found for project '#{name}'.\n" \
15
+ "Start one first with: aircon up #{name}"
16
+ end
17
+
18
+ hex_id = Docker.hex_encode_id(container)
19
+ folder_uri = "vscode-remote://attached-container+#{hex_id}#{@config.workspace_path}"
20
+
21
+ puts "Attaching VS Code to container #{container} for project '#{name}'..."
22
+ system("code", "--folder-uri", folder_uri)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "erb"
5
+
6
+ module Aircon
7
+ class Configuration
8
+ CONFIG_FILE = ".aircon/aircon.yml"
9
+
10
+ DEFAULTS = {
11
+ "compose_file" => ".aircon/docker-compose.yml",
12
+ "app_name" => nil,
13
+ "gh_token" => nil,
14
+ "claude_code_oauth_token" => nil,
15
+ "workspace_path" => nil,
16
+ "claude_config_path" => "~/.claude.json",
17
+ "claude_dir_path" => "~/.claude",
18
+ "service" => "app",
19
+ "git_email" => "claude_docker@localhost.com",
20
+ "git_name" => "Claude Docker",
21
+ "container_user" => "appuser",
22
+ "init_script" => ".aircon/aircon_init.sh"
23
+ }.freeze
24
+
25
+ attr_reader :compose_file, :app_name, :gh_token, :claude_code_oauth_token, :workspace_path,
26
+ :claude_config_path, :claude_dir_path, :service, :git_email, :git_name,
27
+ :container_user, :init_script
28
+
29
+ def initialize(dir: Dir.pwd)
30
+ attrs = DEFAULTS.dup
31
+ config_path = File.join(dir, CONFIG_FILE)
32
+
33
+ if File.exist?(config_path)
34
+ raw = File.read(config_path)
35
+ rendered = ERB.new(raw).result
36
+ user_attrs = YAML.safe_load(rendered) || {}
37
+ attrs.merge!(user_attrs)
38
+ end
39
+
40
+ @compose_file = attrs["compose_file"]
41
+ @app_name = attrs["app_name"] || File.basename(dir)
42
+ @gh_token = attrs["gh_token"]
43
+ @claude_code_oauth_token = attrs["claude_code_oauth_token"]
44
+ @workspace_path = attrs["workspace_path"] || "/workspace"
45
+ @claude_config_path = attrs["claude_config_path"]
46
+ @claude_dir_path = attrs["claude_dir_path"]
47
+ @service = attrs["service"]
48
+ @git_email = attrs["git_email"]
49
+ @git_name = attrs["git_name"]
50
+ @container_user = attrs["container_user"]
51
+ @init_script = attrs["init_script"]
52
+ end
53
+
54
+ def container_home
55
+ @container_user == "root" ? "/root" : "/home/#{@container_user}"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Aircon
6
+ module Docker
7
+ module_function
8
+
9
+ def find_container(project:, service:)
10
+ out, _, status = Open3.capture3(
11
+ "docker", "ps", "-q",
12
+ "--filter", "label=com.docker.compose.project=#{project}",
13
+ "--filter", "label=com.docker.compose.service=#{service}"
14
+ )
15
+ return nil unless status.success?
16
+
17
+ id = out.strip.lines.first&.strip
18
+ id.nil? || id.empty? ? nil : id
19
+ end
20
+
21
+ def hex_encode_id(container_id)
22
+ container_id.each_byte.map { |b| format("%02x", b) }.join
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircon
4
+ VERSION = "0.1.0"
5
+ end
data/lib/aircon.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aircon/version"
4
+ require "aircon/configuration"
5
+ require "aircon/docker"
6
+ require "aircon/commands/up"
7
+ require "aircon/commands/down"
8
+ require "aircon/commands/vscode"
9
+ require "aircon/cli"
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: aircon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Philip Nguyen
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.13'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.13'
40
+ - !ruby/object:Gem::Dependency
41
+ name: fakefs
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: Aircon spins up one Docker Compose environment per git branch, injects
55
+ Claude Code credentials, and attaches an interactive shell or VS Code.
56
+ email:
57
+ - 5519675+philipqnguyen@users.noreply.github.com
58
+ executables:
59
+ - aircon
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - LICENSE.txt
64
+ - README.md
65
+ - exe/aircon
66
+ - lib/aircon.rb
67
+ - lib/aircon/cli.rb
68
+ - lib/aircon/commands/down.rb
69
+ - lib/aircon/commands/up.rb
70
+ - lib/aircon/commands/vscode.rb
71
+ - lib/aircon/configuration.rb
72
+ - lib/aircon/docker.rb
73
+ - lib/aircon/version.rb
74
+ homepage: https://github.com/creativesorcery/aircon
75
+ licenses:
76
+ - MIT
77
+ metadata: {}
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 3.3.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 4.0.5
93
+ specification_version: 4
94
+ summary: Manage Docker-based isolated Claude Code development containers
95
+ test_files: []