dkit 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50d35a0b2d2d8d179be81ce6360eccc29cacd941125ce01dc7202954a7499ab1
4
- data.tar.gz: 727ca198989f81815d6aa2fecb8a2096ababe1402de1f2e73e0cdc07938753dc
3
+ metadata.gz: d15099fc4205be2847823c14188c97dba782e323b71a804ff3c5bef99cb168f3
4
+ data.tar.gz: e63c6b54a4c12bd3a59cfe16d86a499d220e37d3c10874a33dc04fcd2722aa50
5
5
  SHA512:
6
- metadata.gz: 24a65808288983e07fe1897600f467d763518c09954480c6b0dd753e2f310a055c8928e1cd7cf3933c46945ac6a6f2b87eecdb66d754f083e24c20d82153e595
7
- data.tar.gz: 6e604b67d3bafed4b1c90794fd5bf9309a807dbdcd5a0d72f7641f0b5faeb20b7da3a9db2206e983461c43d94204195269c4abfd24ed9b660ec4a603ab5e2e9b
6
+ metadata.gz: 11a9ad7e663c787c54b973666fdbcaa6ed87285a1ab855def63984b443e772bc890dacc2301486f7bd2af23acd41599448b217f6b172388700a7060b16325966
7
+ data.tar.gz: 91eacc0bbac112b98168ed8eee150e16ccf22ee8c6df5b83eb0b3cd6bd5fa66d2aa182c17dc091c02a4e3e8f842ac506052568af995369be3e4a5a83a83d98d0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-04-24
9
+
10
+ ### Changed
11
+ - **Breaking:** Refactored single-file CLI (`bin/dkit`, ~560 lines) into modular structure under `Dkit` namespace (`lib/dkit/`)
12
+ - `bin/dkit` is now a 3-line entry point that delegates to `Dkit::Commands.dispatch`
13
+ - `Dkit::Container` is autoloaded — hot-path commands (`dkit root`, `dkit hook`, `dkit help`, `dkit version`) no longer load `json`/`yaml` parsers
14
+ - Gemspec reads version from `lib/dkit/version.rb` instead of `bin/dkit`
15
+
16
+ ### Added
17
+ - Minitest test suite: 64 tests, 154 assertions covering all modules
18
+ - `Rakefile` with `rake test` task
19
+ - Modules: `Dkit::Project`, `Dkit::Intercept`, `Dkit::Container`, `Dkit::Context`, `Dkit::ShellHook`, `Dkit::Commands`
20
+
21
+ ### Notes
22
+ - No changes to CLI behavior, flags, or intercept file format — fully backwards compatible at the user level
23
+ - Zero external dependencies maintained (minitest is stdlib)
24
+
25
+ ## [0.4.1] - 2026-04-13
26
+
27
+ ### Fixed
28
+ - `dkit shell` now runs as a subprocess (`system`) instead of replacing the current process (`exec`), so `exit` returns to the host shell instead of closing the terminal
29
+
30
+ ### Changed
31
+ - README: added zsh as explicit requirement, documented glob/wildcard intercept patterns, clarified zsh-specific hook mechanisms
32
+
8
33
  ## [0.4.0] - 2026-04-13
9
34
 
10
35
  ### Added
data/README.md CHANGED
@@ -10,6 +10,7 @@ When you `cd` into a project, dkit intercepts configured commands (e.g. `rails`,
10
10
  ## Requirements
11
11
 
12
12
  - macOS or Linux
13
+ - zsh (bash and other shells are not supported)
13
14
  - Ruby >= 2.7
14
15
  - Docker with Compose v2 (`docker compose`)
15
16
  - A project with `.devcontainer/devcontainer.json` using `dockerComposeFile` + `service`
@@ -56,10 +57,13 @@ git commit -m "chore: add dkit intercept config"
56
57
  ```sh
57
58
  dkit intercept list # show active commands for this project
58
59
  dkit intercept add terraform # add a command
60
+ dkit intercept add 'bin/*' # add a glob pattern (quote to prevent shell expansion)
59
61
  dkit intercept remove terraform # remove a command
60
62
  exec zsh # reload shell to apply changes
61
63
  ```
62
64
 
65
+ Glob patterns like `bin/*` intercept all matching executables at once (e.g. `bin/rails`, `bin/rspec`). New files are picked up automatically at each prompt.
66
+
63
67
  ### Verbose routing messages
64
68
 
65
69
  By default, dkit prints a line to stderr whenever it intercepts a command:
@@ -100,17 +104,18 @@ dkit down [flags] docker compose down
100
104
  dkit logs [service] docker compose logs -f
101
105
 
102
106
  dkit init Create .devcontainer/dkit-intercept
103
- dkit intercept list|add|remove <cmd>
107
+ dkit intercept list|add|remove <cmd|pattern>
104
108
  dkit hook Emit shell hook for ~/.zshrc
105
109
  dkit version
106
110
  ```
107
111
 
108
112
  ## How it works
109
113
 
110
- 1. On `cd`, the shell hook calls `dkit root` to find the nearest `.devcontainer/devcontainer.json`.
111
- 2. It reads `.devcontainer/dkit-intercept` and defines a shell function for each listed command.
114
+ 1. On `cd`, the zsh `chpwd` hook calls `dkit root` to find the nearest `.devcontainer/devcontainer.json`.
115
+ 2. It reads `.devcontainer/dkit-intercept` and defines a zsh function for each listed command. Glob patterns (e.g. `bin/*`) are expanded to matching executables.
112
116
  3. Each function calls `dkit status --quiet` to check if the container is running. If yes, it delegates to `dkit run <cmd>`; otherwise it calls the host binary.
113
117
  4. `dkit run` resolves the container name from the devcontainer config (via compose YAML, docker labels, or `docker compose ps`) and execs into it at the mirrored working directory.
118
+ 5. A `precmd` hook re-expands glob patterns before each prompt, picking up new files and cleaning up deleted ones.
114
119
 
115
120
  ## devcontainer.json requirements
116
121
 
data/bin/dkit CHANGED
@@ -1,559 +1,3 @@
1
1
  #!/usr/bin/env ruby
2
- # dkit — DevKit CLI: routes shell commands into a running devcontainer
3
- #
4
- # Install:
5
- # gem install dkit
6
- # echo 'eval "$(dkit hook)"' >> ~/.zshrc && exec zsh
7
- #
8
- # Install from source:
9
- # gem build dkit.gemspec && gem install dkit-*.gem
10
- #
11
- # Usage: dkit help
12
-
13
- require 'json'
14
- require 'yaml'
15
- require 'pathname'
16
- require 'shellwords'
17
- require 'fileutils'
18
-
19
- VERSION = "0.4.0"
20
- DC_CONFIG = ".devcontainer/devcontainer.json"
21
- DC_INTERCEPT = ".devcontainer/dkit-intercept"
22
-
23
- SPECIAL_COMMANDS = %w[code claude].freeze
24
-
25
- # ── Helpers ────────────────────────────────────────────────────────────────────
26
-
27
- def abort_err(msg)
28
- warn "dkit: #{msg}"
29
- exit 1
30
- end
31
-
32
- def docker(*args, capture: false)
33
- cmd = ["docker", *args]
34
- if capture
35
- out = `#{cmd.map(&:shellescape).join(" ")} 2>/dev/null`.strip
36
- out.empty? ? nil : out
37
- else
38
- system(*cmd)
39
- end
40
- end
41
-
42
- # ── Project root (lightweight — no docker) ─────────────────────────────────────
43
-
44
- def find_project_root(from: Dir.pwd)
45
- if (cached = ENV["DKIT_PROJECT_ROOT"]) && !cached.empty? &&
46
- File.exist?(File.join(cached, DC_CONFIG))
47
- return cached
48
- end
49
-
50
- path = Pathname.new(from)
51
- loop do
52
- return path.to_s if (path + DC_CONFIG).exist?
53
- parent = path.parent
54
- return nil if parent == path
55
- path = parent
56
- end
57
- end
58
-
59
- # ── Per-project intercept file ─────────────────────────────────────────────────
60
-
61
- def intercept_file(project_root)
62
- File.join(project_root, DC_INTERCEPT)
63
- end
64
-
65
- def intercept_list(project_root)
66
- f = intercept_file(project_root)
67
- return [] unless File.exist?(f)
68
- File.readlines(f, chomp: true)
69
- .reject { |l| l.strip.empty? || l.strip.start_with?("#") }
70
- .map(&:strip)
71
- .uniq
72
- end
73
-
74
- def intercept_add(project_root, cmd)
75
- list = intercept_list(project_root)
76
- if list.include?(cmd)
77
- puts "dkit: '#{cmd}' is already in the intercept list"
78
- return
79
- end
80
- File.open(intercept_file(project_root), "a") { |f| f.puts cmd }
81
- puts "dkit: added '#{cmd}' — reload shell to activate (exec zsh)"
82
- end
83
-
84
- def intercept_remove(project_root, cmd)
85
- f = intercept_file(project_root)
86
- unless intercept_list(project_root).include?(cmd)
87
- puts "dkit: '#{cmd}' is not in the intercept list"
88
- return
89
- end
90
- lines = File.readlines(f).reject { |l| l.strip == cmd }
91
- File.write(f, lines.join)
92
- puts "dkit: removed '#{cmd}' — reload shell to deactivate (exec zsh)"
93
- end
94
-
95
- def verbose_enabled?(project_root)
96
- return false if ENV["DKIT_VERBOSE"] == "0"
97
- f = intercept_file(project_root)
98
- return true unless File.exist?(f)
99
- !File.readlines(f, chomp: true).any? { |l| l.strip == "verbose: false" }
100
- end
101
-
102
- # ── Devcontainer config ────────────────────────────────────────────────────────
103
-
104
- def load_dc_config(project_root)
105
- raw = File.read(File.join(project_root, DC_CONFIG))
106
- raw = raw.gsub(%r{/\*.*?\*/}m, "").gsub(%r{//[^\n]*}, "")
107
- JSON.parse(raw)
108
- end
109
-
110
- def resolve_container_name(project_root, cfg)
111
- service = cfg["service"]
112
- compose_files = Array(cfg["dockerComposeFile"]).map do |f|
113
- File.expand_path(f, File.join(project_root, ".devcontainer"))
114
- end
115
-
116
- # Strategy A: container_name from compose YAML
117
- compose_files.each do |cf|
118
- next unless File.exist?(cf)
119
- data = YAML.safe_load(File.read(cf))
120
- name = data.dig("services", service, "container_name")
121
- return name if name
122
- end
123
-
124
- # Strategy B: docker label query
125
- project_name = File.basename(project_root).downcase.gsub(/[^a-z0-9]/, "")
126
- name = docker("ps",
127
- "--filter", "label=com.docker.compose.service=#{service}",
128
- "--filter", "label=com.docker.compose.project=#{project_name}",
129
- "--format", "{{.Names}}",
130
- capture: true
131
- )
132
- return name if name
133
-
134
- # Strategy C: docker compose ps -q
135
- first_file = compose_files.first
136
- if first_file && File.exist?(first_file)
137
- id = docker("compose", "-f", first_file, "ps", "-q", service, capture: true)
138
- return id if id
139
- end
140
-
141
- nil
142
- end
143
-
144
- def container_running?(name)
145
- status = docker("inspect", "--format", "{{.State.Status}}", name, capture: true)
146
- status == "running"
147
- end
148
-
149
- def container_cwd(project_root, workspace)
150
- rel = Pathname.new(Dir.pwd).relative_path_from(Pathname.new(project_root)).to_s
151
- rel.start_with?("..") ? workspace : File.join(workspace, rel)
152
- rescue ArgumentError
153
- workspace
154
- end
155
-
156
- # ── Context ────────────────────────────────────────────────────────────────────
157
-
158
- Context = Struct.new(:project_root, :container, :user, :workspace, :cwd, :compose_files, keyword_init: true)
159
-
160
- def resolve!(quiet: false)
161
- root = find_project_root
162
- unless root
163
- quiet ? exit(1) : abort_err("no #{DC_CONFIG} found in #{Dir.pwd} or any parent directory")
164
- end
165
-
166
- cfg = load_dc_config(root)
167
- service = cfg["service"] || "app"
168
- workspace = cfg["workspaceFolder"] || "/workspace"
169
- user = cfg["remoteUser"] || "root"
170
-
171
- container = resolve_container_name(root, cfg)
172
- unless container
173
- quiet ? exit(1) : abort_err("could not determine container name for service '#{service}'")
174
- end
175
-
176
- unless container_running?(container)
177
- quiet ? exit(1) : abort_err("container '#{container}' is not running. Try: dkit up")
178
- end
179
-
180
- compose_files = Array(cfg["dockerComposeFile"]).map do |f|
181
- File.expand_path(f, File.join(root, ".devcontainer"))
182
- end
183
-
184
- Context.new(
185
- project_root: root,
186
- container: container,
187
- user: user,
188
- workspace: workspace,
189
- cwd: container_cwd(root, workspace),
190
- compose_files: compose_files
191
- )
192
- end
193
-
194
- # ── Subcommands ────────────────────────────────────────────────────────────────
195
-
196
- def cmd_root
197
- root = find_project_root
198
- root ? puts(root) : exit(1)
199
- end
200
-
201
- def cmd_init
202
- root = find_project_root
203
- abort_err("no #{DC_CONFIG} found — are you inside a devcontainer project?") unless root
204
-
205
- f = intercept_file(root)
206
- if File.exist?(f)
207
- puts "dkit: #{f} already exists:"
208
- puts File.read(f)
209
- return
210
- end
211
-
212
- cmds = []
213
- cmds += %w[rails bundle rspec rubocop rake] if File.exist?(File.join(root, "Gemfile"))
214
- cmds += %w[yarn node npx] if File.exist?(File.join(root, "package.json"))
215
- cmds = %w[bash] if cmds.empty?
216
-
217
- File.write(f, "# verbose: false # uncomment to suppress routing messages\n" + cmds.join("\n") + "\n")
218
- puts "dkit: created #{f}"
219
- puts "Commands: #{cmds.join(", ")}"
220
- puts "Tip: commit this file to share with your team"
221
- puts " git add #{DC_INTERCEPT} && git commit -m 'chore: add dkit intercept config'"
222
- end
223
-
224
- def cmd_hook
225
- puts <<~'ZSH'
226
- # Generated by: dkit hook
227
- # Add to ~/.zshrc: eval "$(dkit hook)"
228
-
229
- _DKIT_ROOT=""
230
- _DKIT_ACTIVE_CMDS=()
231
- _DKIT_GLOB_PATTERNS=()
232
- _DKIT_GLOB_CMDS=()
233
-
234
- _dkit_reset() {
235
- local cmd
236
- for cmd in "${_DKIT_ACTIVE_CMDS[@]}"; do
237
- unfunction "$cmd" 2>/dev/null
238
- done
239
- _DKIT_ACTIVE_CMDS=()
240
- _DKIT_GLOB_PATTERNS=()
241
- _DKIT_GLOB_CMDS=()
242
- }
243
-
244
- _dkit_verbose_fallback() {
245
- [[ "${DKIT_VERBOSE}" == "0" || -z "${_DKIT_ROOT}" ]] && return
246
- local _ic="${_DKIT_ROOT}/.devcontainer/dkit-intercept"
247
- grep -qxF 'verbose: false' "$_ic" 2>/dev/null && return
248
- printf '\033[31m[dkit] %s → host (fallback)\033[0m\n' "$1" >&2
249
- }
250
-
251
- _dkit_expand_glob() {
252
- local root="$1" pattern="$2"
253
- local full_pattern="$root/$pattern"
254
- local matches=( ${~full_pattern}(N*) )
255
- local m rel
256
- for m in "${matches[@]}"; do
257
- rel="${m#$root/}"
258
- (( ${_DKIT_ACTIVE_CMDS[(Ie)$rel]} )) && continue
259
- eval "function ${rel}() {
260
- if dkit status --quiet 2>/dev/null; then
261
- dkit run ${rel} \"\$@\"
262
- else
263
- _dkit_verbose_fallback \"${rel}\"
264
- command ${rel} \"\$@\"
265
- fi
266
- }"
267
- _DKIT_ACTIVE_CMDS+=("${rel}")
268
- _DKIT_GLOB_CMDS+=("${rel}")
269
- done
270
- }
271
-
272
- _dkit_refresh_globs() {
273
- [[ -z "$_DKIT_ROOT" || ${#_DKIT_GLOB_PATTERNS[@]} -eq 0 ]] && return
274
- local cmd
275
- for cmd in "${_DKIT_GLOB_CMDS[@]}"; do
276
- if [[ ! -e "$_DKIT_ROOT/$cmd" ]]; then
277
- unfunction "$cmd" 2>/dev/null
278
- _DKIT_ACTIVE_CMDS=("${(@)_DKIT_ACTIVE_CMDS:#$cmd}")
279
- fi
280
- done
281
- _DKIT_GLOB_CMDS=()
282
- local pat
283
- for pat in "${_DKIT_GLOB_PATTERNS[@]}"; do
284
- _dkit_expand_glob "$_DKIT_ROOT" "$pat"
285
- done
286
- }
287
-
288
- _dkit_load() {
289
- local root="$1"
290
- local intercept="$root/.devcontainer/dkit-intercept"
291
- [[ -f "$intercept" ]] || return
292
- local cmd
293
- while IFS= read -r cmd; do
294
- [[ -z "$cmd" || "${cmd[1]}" == "#" ]] && continue
295
- # Trim whitespace
296
- cmd="${cmd## }"
297
- cmd="${cmd%% }"
298
- [[ -z "$cmd" ]] && continue
299
- # Glob pattern: expand matching executables
300
- if [[ "$cmd" == *[\*\?\[]* ]]; then
301
- _DKIT_GLOB_PATTERNS+=("${cmd}")
302
- _dkit_expand_glob "$root" "$cmd"
303
- continue
304
- fi
305
- eval "function ${cmd}() {
306
- if dkit status --quiet 2>/dev/null; then
307
- dkit run ${cmd} \"\$@\"
308
- else
309
- _dkit_verbose_fallback \"${cmd}\"
310
- command ${cmd} \"\$@\"
311
- fi
312
- }"
313
- _DKIT_ACTIVE_CMDS+=("${cmd}")
314
- done < "$intercept"
315
- }
316
-
317
- _dkit_chpwd() {
318
- # Fast path: still inside the same project root
319
- if [[ -n "$_DKIT_ROOT" && "$PWD" == "$_DKIT_ROOT"* ]]; then
320
- return
321
- fi
322
- local new_root
323
- new_root="$(dkit root 2>/dev/null || echo '')"
324
- [[ "$new_root" == "$_DKIT_ROOT" ]] && return
325
- _dkit_reset
326
- _DKIT_ROOT="$new_root"
327
- [[ -n "$new_root" ]] && _dkit_load "$new_root"
328
- }
329
-
330
- # Special commands — always available, always route to devcontainer
331
- code() {
332
- if dkit status --quiet 2>/dev/null; then
333
- dkit code "$@"
334
- else
335
- _dkit_verbose_fallback "code"
336
- command code "$@"
337
- fi
338
- }
339
-
340
- claude() {
341
- if dkit status --quiet 2>/dev/null; then
342
- dkit claude "$@"
343
- else
344
- _dkit_verbose_fallback "claude"
345
- command claude "$@"
346
- fi
347
- }
348
-
349
- autoload -U add-zsh-hook
350
- add-zsh-hook chpwd _dkit_chpwd
351
- add-zsh-hook precmd _dkit_refresh_globs
352
- _dkit_chpwd
353
- ZSH
354
- end
355
-
356
- def cmd_exec(ctx, args)
357
- abort_err("exec: no command given") if args.empty?
358
- warn "\e[32m[dkit] #{args.join(" ")} → #{ctx.container}\e[0m" if verbose_enabled?(ctx.project_root)
359
- system("docker", "exec", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, *args)
360
- exit $?.exitstatus
361
- end
362
-
363
- def cmd_run(ctx, args)
364
- abort_err("run: no command given") if args.empty?
365
- warn "\e[32m[dkit] #{args.join(" ")} → #{ctx.container}\e[0m" if verbose_enabled?(ctx.project_root)
366
- exec("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, *args)
367
- end
368
-
369
- def cmd_shell(ctx)
370
- exec("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, "zsh", "-l")
371
- end
372
-
373
- def cmd_claude(ctx, args)
374
- warn "\e[32m[dkit] claude → #{ctx.container}\e[0m" if verbose_enabled?(ctx.project_root)
375
- exec("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, "claude", *args)
376
- end
377
-
378
- def cmd_code(ctx, path_arg)
379
- warn "\e[32m[dkit] code → #{ctx.container}\e[0m" if verbose_enabled?(ctx.project_root)
380
- host_path = path_arg ? File.expand_path(path_arg) : ctx.project_root
381
- rel = begin
382
- Pathname(host_path).relative_path_from(Pathname(ctx.project_root)).to_s
383
- rescue ArgumentError
384
- "."
385
- end
386
- container_path = (rel == ".") ? ctx.workspace : File.join(ctx.workspace, rel)
387
-
388
- payload = JSON.generate({ "hostPath" => ctx.project_root })
389
- hex = payload.unpack1("H*")
390
- uri = "vscode-remote://dev-container+#{hex}#{container_path}"
391
-
392
- if system("which code > /dev/null 2>&1")
393
- exec("code", "--folder-uri", uri)
394
- elsif system("which devcontainer > /dev/null 2>&1")
395
- exec("devcontainer", "open", ctx.project_root)
396
- else
397
- abort_err("'code' CLI not found. In VS Code: Shell Command: Install 'code' command in PATH")
398
- end
399
- end
400
-
401
- def cmd_status(ctx, quiet:)
402
- return if quiet
403
- puts "Project root : #{ctx.project_root}"
404
- puts "Container : #{ctx.container} (running)"
405
- puts "Remote user : #{ctx.user}"
406
- puts "Workspace : #{ctx.workspace}"
407
- puts "Exec CWD : #{ctx.cwd}"
408
- puts "Compose files : #{ctx.compose_files.join(", ")}"
409
- f = intercept_file(ctx.project_root)
410
- if File.exist?(f)
411
- puts "Intercept : #{intercept_list(ctx.project_root).join(", ")}"
412
- else
413
- puts "Intercept : (none — run 'dkit init')"
414
- end
415
- end
416
-
417
- def cmd_compose(ctx, args)
418
- files_flags = ctx.compose_files.flat_map { |f| ["-f", f] }
419
- exec("docker", "compose", *files_flags, *args)
420
- end
421
-
422
- def cmd_help
423
- puts <<~HELP
424
- dkit #{VERSION} — DevKit: routes commands into your devcontainer
425
-
426
- Usage:
427
- dkit exec <cmd> [args] Run command without TTY (scripting)
428
- dkit run <cmd> [args] Run command interactively (TTY)
429
- dkit shell Open interactive shell (zsh) in container
430
- dkit code [path] Open VS Code attached to devcontainer
431
- dkit claude [args] Run claude in container (interactive)
432
-
433
- dkit status Show resolved devcontainer context
434
- dkit status --quiet Exit 0 if container running, 1 otherwise
435
- dkit root Print project root (no docker needed)
436
-
437
- dkit up [service] docker compose up -d
438
- dkit down [flags] docker compose down
439
- dkit logs [service] docker compose logs -f
440
-
441
- dkit init Create .devcontainer/dkit-intercept with auto-detected defaults
442
- dkit intercept list List intercepted commands for current project
443
- dkit intercept add <cmd> Add command to current project's intercept list
444
- dkit intercept add 'bin/*' Add glob pattern (quote to prevent shell expansion)
445
- dkit intercept remove <cmd> Remove command from current project's intercept list
446
-
447
- Verbose routing messages (on by default):
448
- Add 'verbose: false' to .devcontainer/dkit-intercept (per project, committed)
449
- Export DKIT_VERBOSE=0 (personal override)
450
-
451
- dkit hook Emit shell hook code for ~/.zshrc
452
- dkit version Print version
453
- dkit help Show this help
454
-
455
- Shell integration (add to ~/.zshrc):
456
- eval "$(dkit hook)"
457
-
458
- Project setup:
459
- cd ~/projects/my-app
460
- dkit init # creates .devcontainer/dkit-intercept
461
- git add .devcontainer/dkit-intercept && git commit -m "chore: add dkit config"
462
-
463
- Adding a new command to a project:
464
- dkit intercept add terraform
465
- exec zsh
466
- HELP
467
- end
468
-
469
- # ── Entry point ────────────────────────────────────────────────────────────────
470
-
471
- command = ARGV.shift&.downcase
472
-
473
- case command
474
- when "hook"
475
- cmd_hook
476
-
477
- when "root"
478
- cmd_root
479
-
480
- when "init"
481
- cmd_init
482
-
483
- when "intercept"
484
- root = find_project_root
485
- abort_err("no #{DC_CONFIG} found — are you inside a devcontainer project?") unless root
486
-
487
- sub = ARGV.shift&.downcase
488
- case sub
489
- when "list"
490
- list = intercept_list(root)
491
- f = intercept_file(root)
492
- if list.empty?
493
- puts "No intercept file found. Run: dkit init"
494
- else
495
- puts "Intercepted commands (#{f}):"
496
- list.each { |c| puts " #{c}" }
497
- end
498
- puts "\nSpecial (always active): #{SPECIAL_COMMANDS.join(", ")}"
499
- when "add"
500
- abort_err("intercept add: command name required") if ARGV.empty?
501
- intercept_add(root, ARGV.first)
502
- when "remove"
503
- abort_err("intercept remove: command name required") if ARGV.empty?
504
- intercept_remove(root, ARGV.first)
505
- else
506
- abort_err("intercept: unknown subcommand '#{sub}'. Use: list, add, remove")
507
- end
508
-
509
- when "status"
510
- quiet = ARGV.include?("--quiet")
511
- ctx = resolve!(quiet: quiet)
512
- cmd_status(ctx, quiet: quiet)
513
-
514
- when "exec"
515
- ctx = resolve!
516
- cmd_exec(ctx, ARGV)
517
-
518
- when "run"
519
- ctx = resolve!
520
- cmd_run(ctx, ARGV)
521
-
522
- when "shell"
523
- ctx = resolve!
524
- cmd_shell(ctx)
525
-
526
- when "claude"
527
- ctx = resolve!
528
- cmd_claude(ctx, ARGV)
529
-
530
- when "code"
531
- ctx = resolve!
532
- cmd_code(ctx, ARGV.first)
533
-
534
- when "up"
535
- ctx = resolve!(quiet: true) rescue nil
536
- if ctx
537
- cmd_compose(ctx, ["up", "-d", *ARGV])
538
- else
539
- exec("docker", "compose", "up", "-d", *ARGV)
540
- end
541
-
542
- when "down"
543
- ctx = resolve!
544
- cmd_compose(ctx, ["down", *ARGV])
545
-
546
- when "logs"
547
- ctx = resolve!
548
- cmd_compose(ctx, ["logs", "-f", *ARGV])
549
-
550
- when "version", "--version", "-v"
551
- puts "dkit #{VERSION}"
552
-
553
- when nil, "help", "--help", "-h"
554
- cmd_help
555
-
556
- else
557
- warn "dkit: unknown command '#{command}'. Run 'dkit help'."
558
- exit 1
559
- end
2
+ require_relative "../lib/dkit"
3
+ Dkit::Commands.dispatch(ARGV)
@@ -0,0 +1,229 @@
1
+ module Dkit
2
+ module Commands
3
+ module_function
4
+
5
+ def dispatch(argv)
6
+ command = argv.shift&.downcase
7
+
8
+ case command
9
+ when "hook" then cmd_hook
10
+ when "root" then cmd_root
11
+ when "init" then cmd_init
12
+ when "intercept" then cmd_intercept(argv)
13
+ when "status" then cmd_status_dispatch(argv)
14
+ when "exec" then cmd_exec(Dkit.resolve!, argv)
15
+ when "run" then cmd_run(Dkit.resolve!, argv)
16
+ when "shell" then cmd_shell(Dkit.resolve!)
17
+ when "claude" then cmd_claude(Dkit.resolve!, argv)
18
+ when "code" then cmd_code(Dkit.resolve!, argv.first)
19
+ when "up" then cmd_up(argv)
20
+ when "down" then cmd_down(argv)
21
+ when "logs" then cmd_logs(argv)
22
+ when "version", "--version", "-v"
23
+ puts "dkit #{VERSION}"
24
+ when nil, "help", "--help", "-h"
25
+ cmd_help
26
+ else
27
+ warn "dkit: unknown command '#{command}'. Run 'dkit help'."
28
+ exit 1
29
+ end
30
+ end
31
+
32
+ def cmd_root
33
+ root = Project.find_root
34
+ root ? puts(root) : exit(1)
35
+ end
36
+
37
+ def cmd_init
38
+ root = Project.find_root
39
+ Dkit.abort_err("no #{DC_CONFIG} found — are you inside a devcontainer project?") unless root
40
+
41
+ f = Intercept.file_path(root)
42
+ if File.exist?(f)
43
+ puts "dkit: #{f} already exists:"
44
+ puts File.read(f)
45
+ return
46
+ end
47
+
48
+ cmds = []
49
+ cmds += %w[rails bundle rspec rubocop rake] if File.exist?(File.join(root, "Gemfile"))
50
+ cmds += %w[yarn node npx] if File.exist?(File.join(root, "package.json"))
51
+ cmds = %w[bash] if cmds.empty?
52
+
53
+ File.write(f, "# verbose: false # uncomment to suppress routing messages\n" + cmds.join("\n") + "\n")
54
+ puts "dkit: created #{f}"
55
+ puts "Commands: #{cmds.join(", ")}"
56
+ puts "Tip: commit this file to share with your team"
57
+ puts " git add #{DC_INTERCEPT} && git commit -m 'chore: add dkit intercept config'"
58
+ end
59
+
60
+ def cmd_hook
61
+ puts ShellHook.generate
62
+ end
63
+
64
+ def cmd_intercept(argv)
65
+ root = Project.find_root
66
+ Dkit.abort_err("no #{DC_CONFIG} found — are you inside a devcontainer project?") unless root
67
+
68
+ sub = argv.shift&.downcase
69
+ case sub
70
+ when "list"
71
+ list = Intercept.list(root)
72
+ f = Intercept.file_path(root)
73
+ if list.empty?
74
+ puts "No intercept file found. Run: dkit init"
75
+ else
76
+ puts "Intercepted commands (#{f}):"
77
+ list.each { |c| puts " #{c}" }
78
+ end
79
+ puts "\nSpecial (always active): #{SPECIAL_COMMANDS.join(", ")}"
80
+ when "add"
81
+ Dkit.abort_err("intercept add: command name required") if argv.empty?
82
+ Intercept.add(root, argv.first)
83
+ when "remove"
84
+ Dkit.abort_err("intercept remove: command name required") if argv.empty?
85
+ Intercept.remove(root, argv.first)
86
+ else
87
+ Dkit.abort_err("intercept: unknown subcommand '#{sub}'. Use: list, add, remove")
88
+ end
89
+ end
90
+
91
+ def cmd_exec(ctx, args)
92
+ Dkit.abort_err("exec: no command given") if args.empty?
93
+ warn "\e[32m[dkit] #{args.join(" ")} → #{ctx.container}\e[0m" if Intercept.verbose_enabled?(ctx.project_root)
94
+ system("docker", "exec", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, *args)
95
+ exit $?.exitstatus
96
+ end
97
+
98
+ def cmd_run(ctx, args)
99
+ Dkit.abort_err("run: no command given") if args.empty?
100
+ warn "\e[32m[dkit] #{args.join(" ")} → #{ctx.container}\e[0m" if Intercept.verbose_enabled?(ctx.project_root)
101
+ exec("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, *args)
102
+ end
103
+
104
+ def cmd_shell(ctx)
105
+ system("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, "zsh", "-l")
106
+ end
107
+
108
+ def cmd_claude(ctx, args)
109
+ warn "\e[32m[dkit] claude → #{ctx.container}\e[0m" if Intercept.verbose_enabled?(ctx.project_root)
110
+ exec("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, "claude", *args)
111
+ end
112
+
113
+ def cmd_code(ctx, path_arg)
114
+ warn "\e[32m[dkit] code → #{ctx.container}\e[0m" if Intercept.verbose_enabled?(ctx.project_root)
115
+ host_path = path_arg ? File.expand_path(path_arg) : ctx.project_root
116
+ rel = begin
117
+ Pathname(host_path).relative_path_from(Pathname(ctx.project_root)).to_s
118
+ rescue ArgumentError
119
+ "."
120
+ end
121
+ container_path = (rel == ".") ? ctx.workspace : File.join(ctx.workspace, rel)
122
+
123
+ payload = JSON.generate({ "hostPath" => ctx.project_root })
124
+ hex = payload.unpack1("H*")
125
+ uri = "vscode-remote://dev-container+#{hex}#{container_path}"
126
+
127
+ if system("which code > /dev/null 2>&1")
128
+ exec("code", "--folder-uri", uri)
129
+ elsif system("which devcontainer > /dev/null 2>&1")
130
+ exec("devcontainer", "open", ctx.project_root)
131
+ else
132
+ Dkit.abort_err("'code' CLI not found. In VS Code: Shell Command: Install 'code' command in PATH")
133
+ end
134
+ end
135
+
136
+ def cmd_status_dispatch(argv)
137
+ quiet = argv.include?("--quiet")
138
+ ctx = Dkit.resolve!(quiet: quiet)
139
+ cmd_status(ctx, quiet: quiet)
140
+ end
141
+
142
+ def cmd_status(ctx, quiet:)
143
+ return if quiet
144
+ puts "Project root : #{ctx.project_root}"
145
+ puts "Container : #{ctx.container} (running)"
146
+ puts "Remote user : #{ctx.user}"
147
+ puts "Workspace : #{ctx.workspace}"
148
+ puts "Exec CWD : #{ctx.cwd}"
149
+ puts "Compose files : #{ctx.compose_files.join(", ")}"
150
+ f = Intercept.file_path(ctx.project_root)
151
+ if File.exist?(f)
152
+ puts "Intercept : #{Intercept.list(ctx.project_root).join(", ")}"
153
+ else
154
+ puts "Intercept : (none — run 'dkit init')"
155
+ end
156
+ end
157
+
158
+ def cmd_compose(ctx, args)
159
+ files_flags = ctx.compose_files.flat_map { |f| ["-f", f] }
160
+ exec("docker", "compose", *files_flags, *args)
161
+ end
162
+
163
+ def cmd_up(argv)
164
+ ctx = Dkit.resolve!(quiet: true) rescue nil
165
+ if ctx
166
+ cmd_compose(ctx, ["up", "-d", *argv])
167
+ else
168
+ exec("docker", "compose", "up", "-d", *argv)
169
+ end
170
+ end
171
+
172
+ def cmd_down(argv)
173
+ ctx = Dkit.resolve!
174
+ cmd_compose(ctx, ["down", *argv])
175
+ end
176
+
177
+ def cmd_logs(argv)
178
+ ctx = Dkit.resolve!
179
+ cmd_compose(ctx, ["logs", "-f", *argv])
180
+ end
181
+
182
+ def cmd_help
183
+ puts <<~HELP
184
+ dkit #{VERSION} — DevKit: routes commands into your devcontainer
185
+
186
+ Usage:
187
+ dkit exec <cmd> [args] Run command without TTY (scripting)
188
+ dkit run <cmd> [args] Run command interactively (TTY)
189
+ dkit shell Open interactive shell (zsh) in container
190
+ dkit code [path] Open VS Code attached to devcontainer
191
+ dkit claude [args] Run claude in container (interactive)
192
+
193
+ dkit status Show resolved devcontainer context
194
+ dkit status --quiet Exit 0 if container running, 1 otherwise
195
+ dkit root Print project root (no docker needed)
196
+
197
+ dkit up [service] docker compose up -d
198
+ dkit down [flags] docker compose down
199
+ dkit logs [service] docker compose logs -f
200
+
201
+ dkit init Create .devcontainer/dkit-intercept with auto-detected defaults
202
+ dkit intercept list List intercepted commands for current project
203
+ dkit intercept add <cmd> Add command to current project's intercept list
204
+ dkit intercept add 'bin/*' Add glob pattern (quote to prevent shell expansion)
205
+ dkit intercept remove <cmd> Remove command from current project's intercept list
206
+
207
+ Verbose routing messages (on by default):
208
+ Add 'verbose: false' to .devcontainer/dkit-intercept (per project, committed)
209
+ Export DKIT_VERBOSE=0 (personal override)
210
+
211
+ dkit hook Emit shell hook code for ~/.zshrc
212
+ dkit version Print version
213
+ dkit help Show this help
214
+
215
+ Shell integration (add to ~/.zshrc):
216
+ eval "$(dkit hook)"
217
+
218
+ Project setup:
219
+ cd ~/projects/my-app
220
+ dkit init # creates .devcontainer/dkit-intercept
221
+ git add .devcontainer/dkit-intercept && git commit -m "chore: add dkit config"
222
+
223
+ Adding a new command to a project:
224
+ dkit intercept add terraform
225
+ exec zsh
226
+ HELP
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,71 @@
1
+ require "json"
2
+ require "yaml"
3
+ require "shellwords"
4
+
5
+ module Dkit
6
+ module Container
7
+ module_function
8
+
9
+ def docker(*args, capture: false)
10
+ cmd = ["docker", *args]
11
+ if capture
12
+ out = `#{cmd.map(&:shellescape).join(" ")} 2>/dev/null`.strip
13
+ out.empty? ? nil : out
14
+ else
15
+ system(*cmd)
16
+ end
17
+ end
18
+
19
+ def load_dc_config(project_root)
20
+ raw = File.read(File.join(project_root, DC_CONFIG))
21
+ raw = raw.gsub(%r{/\*.*?\*/}m, "").gsub(%r{//[^\n]*}, "")
22
+ JSON.parse(raw)
23
+ end
24
+
25
+ def resolve_name(project_root, cfg)
26
+ service = cfg["service"]
27
+ compose_files = Array(cfg["dockerComposeFile"]).map do |f|
28
+ File.expand_path(f, File.join(project_root, ".devcontainer"))
29
+ end
30
+
31
+ # Strategy A: container_name from compose YAML
32
+ compose_files.each do |cf|
33
+ next unless File.exist?(cf)
34
+ data = YAML.safe_load(File.read(cf))
35
+ name = data.dig("services", service, "container_name")
36
+ return name if name
37
+ end
38
+
39
+ # Strategy B: docker label query
40
+ project_name = File.basename(project_root).downcase.gsub(/[^a-z0-9]/, "")
41
+ name = docker("ps",
42
+ "--filter", "label=com.docker.compose.service=#{service}",
43
+ "--filter", "label=com.docker.compose.project=#{project_name}",
44
+ "--format", "{{.Names}}",
45
+ capture: true
46
+ )
47
+ return name if name
48
+
49
+ # Strategy C: docker compose ps -q
50
+ first_file = compose_files.first
51
+ if first_file && File.exist?(first_file)
52
+ id = docker("compose", "-f", first_file, "ps", "-q", service, capture: true)
53
+ return id if id
54
+ end
55
+
56
+ nil
57
+ end
58
+
59
+ def running?(name)
60
+ status = docker("inspect", "--format", "{{.State.Status}}", name, capture: true)
61
+ status == "running"
62
+ end
63
+
64
+ def cwd(project_root, workspace)
65
+ rel = Pathname.new(Dir.pwd).relative_path_from(Pathname.new(project_root)).to_s
66
+ rel.start_with?("..") ? workspace : File.join(workspace, rel)
67
+ rescue ArgumentError
68
+ workspace
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,44 @@
1
+ module Dkit
2
+ Context = Struct.new(:project_root, :container, :user, :workspace, :cwd, :compose_files, keyword_init: true)
3
+
4
+ module_function
5
+
6
+ def abort_err(msg)
7
+ warn "dkit: #{msg}"
8
+ exit 1
9
+ end
10
+
11
+ def resolve!(quiet: false)
12
+ root = Project.find_root
13
+ unless root
14
+ quiet ? exit(1) : abort_err("no #{DC_CONFIG} found in #{Dir.pwd} or any parent directory")
15
+ end
16
+
17
+ cfg = Container.load_dc_config(root)
18
+ service = cfg["service"] || "app"
19
+ workspace = cfg["workspaceFolder"] || "/workspace"
20
+ user = cfg["remoteUser"] || "root"
21
+
22
+ container = Container.resolve_name(root, cfg)
23
+ unless container
24
+ quiet ? exit(1) : abort_err("could not determine container name for service '#{service}'")
25
+ end
26
+
27
+ unless Container.running?(container)
28
+ quiet ? exit(1) : abort_err("container '#{container}' is not running. Try: dkit up")
29
+ end
30
+
31
+ compose_files = Array(cfg["dockerComposeFile"]).map do |f|
32
+ File.expand_path(f, File.join(root, ".devcontainer"))
33
+ end
34
+
35
+ Context.new(
36
+ project_root: root,
37
+ container: container,
38
+ user: user,
39
+ workspace: workspace,
40
+ cwd: Container.cwd(root, workspace),
41
+ compose_files: compose_files
42
+ )
43
+ end
44
+ end
@@ -0,0 +1,48 @@
1
+ require "fileutils"
2
+
3
+ module Dkit
4
+ module Intercept
5
+ module_function
6
+
7
+ def file_path(project_root)
8
+ File.join(project_root, DC_INTERCEPT)
9
+ end
10
+
11
+ def list(project_root)
12
+ f = file_path(project_root)
13
+ return [] unless File.exist?(f)
14
+ File.readlines(f, chomp: true)
15
+ .reject { |l| l.strip.empty? || l.strip.start_with?("#") }
16
+ .map(&:strip)
17
+ .uniq
18
+ end
19
+
20
+ def add(project_root, cmd)
21
+ current = list(project_root)
22
+ if current.include?(cmd)
23
+ puts "dkit: '#{cmd}' is already in the intercept list"
24
+ return
25
+ end
26
+ File.open(file_path(project_root), "a") { |f| f.puts cmd }
27
+ puts "dkit: added '#{cmd}' — reload shell to activate (exec zsh)"
28
+ end
29
+
30
+ def remove(project_root, cmd)
31
+ f = file_path(project_root)
32
+ unless list(project_root).include?(cmd)
33
+ puts "dkit: '#{cmd}' is not in the intercept list"
34
+ return
35
+ end
36
+ lines = File.readlines(f).reject { |l| l.strip == cmd }
37
+ File.write(f, lines.join)
38
+ puts "dkit: removed '#{cmd}' — reload shell to deactivate (exec zsh)"
39
+ end
40
+
41
+ def verbose_enabled?(project_root)
42
+ return false if ENV["DKIT_VERBOSE"] == "0"
43
+ f = file_path(project_root)
44
+ return true unless File.exist?(f)
45
+ !File.readlines(f, chomp: true).any? { |l| l.strip == "verbose: false" }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ require "pathname"
2
+
3
+ module Dkit
4
+ DC_CONFIG = ".devcontainer/devcontainer.json"
5
+ DC_INTERCEPT = ".devcontainer/dkit-intercept"
6
+ SPECIAL_COMMANDS = %w[code claude].freeze
7
+
8
+ module Project
9
+ module_function
10
+
11
+ def find_root(from: Dir.pwd)
12
+ if (cached = ENV["DKIT_PROJECT_ROOT"]) && !cached.empty? &&
13
+ File.exist?(File.join(cached, DC_CONFIG))
14
+ return cached
15
+ end
16
+
17
+ path = Pathname.new(File.realpath(from))
18
+ loop do
19
+ return path.to_s if (path + DC_CONFIG).exist?
20
+ parent = path.parent
21
+ return nil if parent == path
22
+ path = parent
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,137 @@
1
+ module Dkit
2
+ module ShellHook
3
+ module_function
4
+
5
+ def generate
6
+ <<~'ZSH'
7
+ # Generated by: dkit hook
8
+ # Add to ~/.zshrc: eval "$(dkit hook)"
9
+
10
+ _DKIT_ROOT=""
11
+ _DKIT_ACTIVE_CMDS=()
12
+ _DKIT_GLOB_PATTERNS=()
13
+ _DKIT_GLOB_CMDS=()
14
+
15
+ _dkit_reset() {
16
+ local cmd
17
+ for cmd in "${_DKIT_ACTIVE_CMDS[@]}"; do
18
+ unfunction "$cmd" 2>/dev/null
19
+ done
20
+ _DKIT_ACTIVE_CMDS=()
21
+ _DKIT_GLOB_PATTERNS=()
22
+ _DKIT_GLOB_CMDS=()
23
+ }
24
+
25
+ _dkit_verbose_fallback() {
26
+ [[ "${DKIT_VERBOSE}" == "0" || -z "${_DKIT_ROOT}" ]] && return
27
+ local _ic="${_DKIT_ROOT}/.devcontainer/dkit-intercept"
28
+ grep -qxF 'verbose: false' "$_ic" 2>/dev/null && return
29
+ printf '\033[31m[dkit] %s → host (fallback)\033[0m\n' "$1" >&2
30
+ }
31
+
32
+ _dkit_expand_glob() {
33
+ local root="$1" pattern="$2"
34
+ local full_pattern="$root/$pattern"
35
+ local matches=( ${~full_pattern}(N*) )
36
+ local m rel
37
+ for m in "${matches[@]}"; do
38
+ rel="${m#$root/}"
39
+ (( ${_DKIT_ACTIVE_CMDS[(Ie)$rel]} )) && continue
40
+ eval "function ${rel}() {
41
+ if dkit status --quiet 2>/dev/null; then
42
+ dkit run ${rel} \"\$@\"
43
+ else
44
+ _dkit_verbose_fallback \"${rel}\"
45
+ command ${rel} \"\$@\"
46
+ fi
47
+ }"
48
+ _DKIT_ACTIVE_CMDS+=("${rel}")
49
+ _DKIT_GLOB_CMDS+=("${rel}")
50
+ done
51
+ }
52
+
53
+ _dkit_refresh_globs() {
54
+ [[ -z "$_DKIT_ROOT" || ${#_DKIT_GLOB_PATTERNS[@]} -eq 0 ]] && return
55
+ local cmd
56
+ for cmd in "${_DKIT_GLOB_CMDS[@]}"; do
57
+ if [[ ! -e "$_DKIT_ROOT/$cmd" ]]; then
58
+ unfunction "$cmd" 2>/dev/null
59
+ _DKIT_ACTIVE_CMDS=("${(@)_DKIT_ACTIVE_CMDS:#$cmd}")
60
+ fi
61
+ done
62
+ _DKIT_GLOB_CMDS=()
63
+ local pat
64
+ for pat in "${_DKIT_GLOB_PATTERNS[@]}"; do
65
+ _dkit_expand_glob "$_DKIT_ROOT" "$pat"
66
+ done
67
+ }
68
+
69
+ _dkit_load() {
70
+ local root="$1"
71
+ local intercept="$root/.devcontainer/dkit-intercept"
72
+ [[ -f "$intercept" ]] || return
73
+ local cmd
74
+ while IFS= read -r cmd; do
75
+ [[ -z "$cmd" || "${cmd[1]}" == "#" ]] && continue
76
+ # Trim whitespace
77
+ cmd="${cmd## }"
78
+ cmd="${cmd%% }"
79
+ [[ -z "$cmd" ]] && continue
80
+ # Glob pattern: expand matching executables
81
+ if [[ "$cmd" == *[\*\?\[]* ]]; then
82
+ _DKIT_GLOB_PATTERNS+=("${cmd}")
83
+ _dkit_expand_glob "$root" "$cmd"
84
+ continue
85
+ fi
86
+ eval "function ${cmd}() {
87
+ if dkit status --quiet 2>/dev/null; then
88
+ dkit run ${cmd} \"\$@\"
89
+ else
90
+ _dkit_verbose_fallback \"${cmd}\"
91
+ command ${cmd} \"\$@\"
92
+ fi
93
+ }"
94
+ _DKIT_ACTIVE_CMDS+=("${cmd}")
95
+ done < "$intercept"
96
+ }
97
+
98
+ _dkit_chpwd() {
99
+ # Fast path: still inside the same project root
100
+ if [[ -n "$_DKIT_ROOT" && "$PWD" == "$_DKIT_ROOT"* ]]; then
101
+ return
102
+ fi
103
+ local new_root
104
+ new_root="$(dkit root 2>/dev/null || echo '')"
105
+ [[ "$new_root" == "$_DKIT_ROOT" ]] && return
106
+ _dkit_reset
107
+ _DKIT_ROOT="$new_root"
108
+ [[ -n "$new_root" ]] && _dkit_load "$new_root"
109
+ }
110
+
111
+ # Special commands — always available, always route to devcontainer
112
+ code() {
113
+ if dkit status --quiet 2>/dev/null; then
114
+ dkit code "$@"
115
+ else
116
+ _dkit_verbose_fallback "code"
117
+ command code "$@"
118
+ fi
119
+ }
120
+
121
+ claude() {
122
+ if dkit status --quiet 2>/dev/null; then
123
+ dkit claude "$@"
124
+ else
125
+ _dkit_verbose_fallback "claude"
126
+ command claude "$@"
127
+ fi
128
+ }
129
+
130
+ autoload -U add-zsh-hook
131
+ add-zsh-hook chpwd _dkit_chpwd
132
+ add-zsh-hook precmd _dkit_refresh_globs
133
+ _dkit_chpwd
134
+ ZSH
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,3 @@
1
+ module Dkit
2
+ VERSION = "0.5.0"
3
+ end
data/lib/dkit.rb ADDED
@@ -0,0 +1,10 @@
1
+ module Dkit
2
+ autoload :Container, File.expand_path("dkit/container", __dir__)
3
+ end
4
+
5
+ require_relative "dkit/version"
6
+ require_relative "dkit/project"
7
+ require_relative "dkit/intercept"
8
+ require_relative "dkit/context"
9
+ require_relative "dkit/shell_hook"
10
+ require_relative "dkit/commands"
metadata CHANGED
@@ -1,19 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Augusto Stroligo
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-13 00:00:00.000000000 Z
11
+ date: 2026-04-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Routes shell commands transparently into a running devcontainer with
14
14
  shell hook integration, per-project intercept lists, VS Code attachment, and docker
15
15
  compose helpers.
16
- email:
16
+ email:
17
17
  executables:
18
18
  - dkit
19
19
  extensions: []
@@ -23,6 +23,14 @@ files:
23
23
  - LICENSE
24
24
  - README.md
25
25
  - bin/dkit
26
+ - lib/dkit.rb
27
+ - lib/dkit/commands.rb
28
+ - lib/dkit/container.rb
29
+ - lib/dkit/context.rb
30
+ - lib/dkit/intercept.rb
31
+ - lib/dkit/project.rb
32
+ - lib/dkit/shell_hook.rb
33
+ - lib/dkit/version.rb
26
34
  homepage: https://github.com/ggstroligo/dkit
27
35
  licenses:
28
36
  - MIT
@@ -31,7 +39,7 @@ metadata:
31
39
  changelog_uri: https://github.com/ggstroligo/dkit/blob/main/CHANGELOG.md
32
40
  bug_tracker_uri: https://github.com/ggstroligo/dkit/issues
33
41
  rubygems_mfa_required: 'true'
34
- post_install_message:
42
+ post_install_message:
35
43
  rdoc_options: []
36
44
  require_paths:
37
45
  - lib
@@ -46,8 +54,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
54
  - !ruby/object:Gem::Version
47
55
  version: '0'
48
56
  requirements: []
49
- rubygems_version: 3.5.22
50
- signing_key:
57
+ rubygems_version: 3.0.3.1
58
+ signing_key:
51
59
  specification_version: 4
52
60
  summary: 'DevKit CLI: routes shell commands into a running devcontainer'
53
61
  test_files: []