dkit 0.2.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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/bin/dkit +485 -0
  3. metadata +46 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 53e0c6ec379900435c8ecf139f2960b5438df945e674a6709be32a71732fd9e9
4
+ data.tar.gz: f2b8ad870c59fc01acba6640e6c02cb13216a303373cfb7e008a2dd3d1e4d7fd
5
+ SHA512:
6
+ metadata.gz: 788dcd77ebde166204346894dacc6772fbd04fe613acef1a6b72f16fff26f7894a238948d3d26c59377a942c9b28081d28a9b03b366b59a8cddf0372df1dd536
7
+ data.tar.gz: 1d19f03a3b15edf459ad0d20806ed4abce82983b3db85fedd98be820f1b6a9b96a6ee351fc4294908b8cdec56ab496a432bb1eb8bf1a67bb665af86a2fe639e7
data/bin/dkit ADDED
@@ -0,0 +1,485 @@
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.2.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
+ # ── Devcontainer config ────────────────────────────────────────────────────────
96
+
97
+ def load_dc_config(project_root)
98
+ raw = File.read(File.join(project_root, DC_CONFIG))
99
+ raw = raw.gsub(%r{/\*.*?\*/}m, "").gsub(%r{//[^\n]*}, "")
100
+ JSON.parse(raw)
101
+ end
102
+
103
+ def resolve_container_name(project_root, cfg)
104
+ service = cfg["service"]
105
+ compose_files = Array(cfg["dockerComposeFile"]).map do |f|
106
+ File.expand_path(f, File.join(project_root, ".devcontainer"))
107
+ end
108
+
109
+ # Strategy A: container_name from compose YAML
110
+ compose_files.each do |cf|
111
+ next unless File.exist?(cf)
112
+ data = YAML.safe_load(File.read(cf))
113
+ name = data.dig("services", service, "container_name")
114
+ return name if name
115
+ end
116
+
117
+ # Strategy B: docker label query
118
+ project_name = File.basename(project_root).downcase.gsub(/[^a-z0-9]/, "")
119
+ name = docker("ps",
120
+ "--filter", "label=com.docker.compose.service=#{service}",
121
+ "--filter", "label=com.docker.compose.project=#{project_name}",
122
+ "--format", "{{.Names}}",
123
+ capture: true
124
+ )
125
+ return name if name
126
+
127
+ # Strategy C: docker compose ps -q
128
+ first_file = compose_files.first
129
+ if first_file && File.exist?(first_file)
130
+ id = docker("compose", "-f", first_file, "ps", "-q", service, capture: true)
131
+ return id if id
132
+ end
133
+
134
+ nil
135
+ end
136
+
137
+ def container_running?(name)
138
+ status = docker("inspect", "--format", "{{.State.Status}}", name, capture: true)
139
+ status == "running"
140
+ end
141
+
142
+ def container_cwd(project_root, workspace)
143
+ rel = Pathname.new(Dir.pwd).relative_path_from(Pathname.new(project_root)).to_s
144
+ rel.start_with?("..") ? workspace : File.join(workspace, rel)
145
+ rescue ArgumentError
146
+ workspace
147
+ end
148
+
149
+ # ── Context ────────────────────────────────────────────────────────────────────
150
+
151
+ Context = Struct.new(:project_root, :container, :user, :workspace, :cwd, :compose_files, keyword_init: true)
152
+
153
+ def resolve!(quiet: false)
154
+ root = find_project_root
155
+ unless root
156
+ quiet ? exit(1) : abort_err("no #{DC_CONFIG} found in #{Dir.pwd} or any parent directory")
157
+ end
158
+
159
+ cfg = load_dc_config(root)
160
+ service = cfg["service"] || "app"
161
+ workspace = cfg["workspaceFolder"] || "/workspace"
162
+ user = cfg["remoteUser"] || "root"
163
+
164
+ container = resolve_container_name(root, cfg)
165
+ unless container
166
+ quiet ? exit(1) : abort_err("could not determine container name for service '#{service}'")
167
+ end
168
+
169
+ unless container_running?(container)
170
+ quiet ? exit(1) : abort_err("container '#{container}' is not running. Try: dkit up")
171
+ end
172
+
173
+ compose_files = Array(cfg["dockerComposeFile"]).map do |f|
174
+ File.expand_path(f, File.join(root, ".devcontainer"))
175
+ end
176
+
177
+ Context.new(
178
+ project_root: root,
179
+ container: container,
180
+ user: user,
181
+ workspace: workspace,
182
+ cwd: container_cwd(root, workspace),
183
+ compose_files: compose_files
184
+ )
185
+ end
186
+
187
+ # ── Subcommands ────────────────────────────────────────────────────────────────
188
+
189
+ def cmd_root
190
+ root = find_project_root
191
+ root ? puts(root) : exit(1)
192
+ end
193
+
194
+ def cmd_init
195
+ root = find_project_root
196
+ abort_err("no #{DC_CONFIG} found — are you inside a devcontainer project?") unless root
197
+
198
+ f = intercept_file(root)
199
+ if File.exist?(f)
200
+ puts "dkit: #{f} already exists:"
201
+ puts File.read(f)
202
+ return
203
+ end
204
+
205
+ cmds = []
206
+ cmds += %w[rails bundle rspec rubocop rake] if File.exist?(File.join(root, "Gemfile"))
207
+ cmds += %w[yarn node npx] if File.exist?(File.join(root, "package.json"))
208
+ cmds = %w[bash] if cmds.empty?
209
+
210
+ File.write(f, cmds.join("\n") + "\n")
211
+ puts "dkit: created #{f}"
212
+ puts "Commands: #{cmds.join(", ")}"
213
+ puts "Tip: commit this file to share with your team"
214
+ puts " git add #{DC_INTERCEPT} && git commit -m 'chore: add dkit intercept config'"
215
+ end
216
+
217
+ def cmd_hook
218
+ puts <<~'ZSH'
219
+ # Generated by: dkit hook
220
+ # Add to ~/.zshrc: eval "$(dkit hook)"
221
+
222
+ _DKIT_ROOT=""
223
+ _DKIT_ACTIVE_CMDS=()
224
+
225
+ _dkit_reset() {
226
+ local cmd
227
+ for cmd in "${_DKIT_ACTIVE_CMDS[@]}"; do
228
+ unfunction "$cmd" 2>/dev/null
229
+ done
230
+ _DKIT_ACTIVE_CMDS=()
231
+ }
232
+
233
+ _dkit_load() {
234
+ local root="$1"
235
+ local intercept="$root/.devcontainer/dkit-intercept"
236
+ [[ -f "$intercept" ]] || return
237
+ local cmd
238
+ while IFS= read -r cmd; do
239
+ [[ -z "$cmd" || "${cmd[1]}" == "#" ]] && continue
240
+ # Trim whitespace
241
+ cmd="${cmd## }"
242
+ cmd="${cmd%% }"
243
+ [[ -z "$cmd" ]] && continue
244
+ eval "function ${cmd}() {
245
+ if dkit status --quiet 2>/dev/null; then
246
+ dkit run ${cmd} \"\$@\"
247
+ else
248
+ command ${cmd} \"\$@\"
249
+ fi
250
+ }"
251
+ _DKIT_ACTIVE_CMDS+=("${cmd}")
252
+ done < "$intercept"
253
+ }
254
+
255
+ _dkit_chpwd() {
256
+ # Fast path: still inside the same project root
257
+ if [[ -n "$_DKIT_ROOT" && "$PWD" == "$_DKIT_ROOT"* ]]; then
258
+ return
259
+ fi
260
+ local new_root
261
+ new_root="$(dkit root 2>/dev/null || echo '')"
262
+ [[ "$new_root" == "$_DKIT_ROOT" ]] && return
263
+ _dkit_reset
264
+ _DKIT_ROOT="$new_root"
265
+ [[ -n "$new_root" ]] && _dkit_load "$new_root"
266
+ }
267
+
268
+ # Special commands — always available, always route to devcontainer
269
+ code() {
270
+ if dkit status --quiet 2>/dev/null; then
271
+ dkit code "$@"
272
+ else
273
+ command code "$@"
274
+ fi
275
+ }
276
+
277
+ claude() {
278
+ if dkit status --quiet 2>/dev/null; then
279
+ dkit claude "$@"
280
+ else
281
+ command claude "$@"
282
+ fi
283
+ }
284
+
285
+ autoload -U add-zsh-hook
286
+ add-zsh-hook chpwd _dkit_chpwd
287
+ _dkit_chpwd
288
+ ZSH
289
+ end
290
+
291
+ def cmd_exec(ctx, args)
292
+ abort_err("exec: no command given") if args.empty?
293
+ system("docker", "exec", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, *args)
294
+ exit $?.exitstatus
295
+ end
296
+
297
+ def cmd_run(ctx, args)
298
+ abort_err("run: no command given") if args.empty?
299
+ exec("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, *args)
300
+ end
301
+
302
+ def cmd_shell(ctx)
303
+ exec("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, "zsh", "-l")
304
+ end
305
+
306
+ def cmd_claude(ctx, args)
307
+ exec("docker", "exec", "-it", "--user", ctx.user, "--workdir", ctx.cwd, ctx.container, "claude", *args)
308
+ end
309
+
310
+ def cmd_code(ctx, path_arg)
311
+ host_path = path_arg ? File.expand_path(path_arg) : ctx.project_root
312
+ rel = begin
313
+ Pathname(host_path).relative_path_from(Pathname(ctx.project_root)).to_s
314
+ rescue ArgumentError
315
+ "."
316
+ end
317
+ container_path = (rel == ".") ? ctx.workspace : File.join(ctx.workspace, rel)
318
+
319
+ payload = JSON.generate({ "hostPath" => ctx.project_root })
320
+ hex = payload.unpack1("H*")
321
+ uri = "vscode-remote://dev-container+#{hex}#{container_path}"
322
+
323
+ if system("which code > /dev/null 2>&1")
324
+ exec("code", "--folder-uri", uri)
325
+ elsif system("which devcontainer > /dev/null 2>&1")
326
+ exec("devcontainer", "open", ctx.project_root)
327
+ else
328
+ abort_err("'code' CLI not found. In VS Code: Shell Command: Install 'code' command in PATH")
329
+ end
330
+ end
331
+
332
+ def cmd_status(ctx, quiet:)
333
+ return if quiet
334
+ puts "Project root : #{ctx.project_root}"
335
+ puts "Container : #{ctx.container} (running)"
336
+ puts "Remote user : #{ctx.user}"
337
+ puts "Workspace : #{ctx.workspace}"
338
+ puts "Exec CWD : #{ctx.cwd}"
339
+ puts "Compose files : #{ctx.compose_files.join(", ")}"
340
+ f = intercept_file(ctx.project_root)
341
+ if File.exist?(f)
342
+ puts "Intercept : #{intercept_list(ctx.project_root).join(", ")}"
343
+ else
344
+ puts "Intercept : (none — run 'dkit init')"
345
+ end
346
+ end
347
+
348
+ def cmd_compose(ctx, args)
349
+ files_flags = ctx.compose_files.flat_map { |f| ["-f", f] }
350
+ exec("docker", "compose", *files_flags, *args)
351
+ end
352
+
353
+ def cmd_help
354
+ puts <<~HELP
355
+ dkit #{VERSION} — DevKit: routes commands into your devcontainer
356
+
357
+ Usage:
358
+ dkit exec <cmd> [args] Run command without TTY (scripting)
359
+ dkit run <cmd> [args] Run command interactively (TTY)
360
+ dkit shell Open interactive shell (zsh) in container
361
+ dkit code [path] Open VS Code attached to devcontainer
362
+ dkit claude [args] Run claude in container (interactive)
363
+
364
+ dkit status Show resolved devcontainer context
365
+ dkit status --quiet Exit 0 if container running, 1 otherwise
366
+ dkit root Print project root (no docker needed)
367
+
368
+ dkit up [service] docker compose up -d
369
+ dkit down [flags] docker compose down
370
+ dkit logs [service] docker compose logs -f
371
+
372
+ dkit init Create .devcontainer/dkit-intercept with auto-detected defaults
373
+ dkit intercept list List intercepted commands for current project
374
+ dkit intercept add <cmd> Add command to current project's intercept list
375
+ dkit intercept remove <cmd> Remove command from current project's intercept list
376
+
377
+ dkit hook Emit shell hook code for ~/.zshrc
378
+ dkit version Print version
379
+ dkit help Show this help
380
+
381
+ Shell integration (add to ~/.zshrc):
382
+ eval "$(dkit hook)"
383
+
384
+ Project setup:
385
+ cd ~/projects/my-app
386
+ dkit init # creates .devcontainer/dkit-intercept
387
+ git add .devcontainer/dkit-intercept && git commit -m "chore: add dkit config"
388
+
389
+ Adding a new command to a project:
390
+ dkit intercept add terraform
391
+ exec zsh
392
+ HELP
393
+ end
394
+
395
+ # ── Entry point ────────────────────────────────────────────────────────────────
396
+
397
+ command = ARGV.shift&.downcase
398
+
399
+ case command
400
+ when "hook"
401
+ cmd_hook
402
+
403
+ when "root"
404
+ cmd_root
405
+
406
+ when "init"
407
+ cmd_init
408
+
409
+ when "intercept"
410
+ root = find_project_root
411
+ abort_err("no #{DC_CONFIG} found — are you inside a devcontainer project?") unless root
412
+
413
+ sub = ARGV.shift&.downcase
414
+ case sub
415
+ when "list"
416
+ list = intercept_list(root)
417
+ f = intercept_file(root)
418
+ if list.empty?
419
+ puts "No intercept file found. Run: dkit init"
420
+ else
421
+ puts "Intercepted commands (#{f}):"
422
+ list.each { |c| puts " #{c}" }
423
+ end
424
+ puts "\nSpecial (always active): #{SPECIAL_COMMANDS.join(", ")}"
425
+ when "add"
426
+ abort_err("intercept add: command name required") if ARGV.empty?
427
+ intercept_add(root, ARGV.first)
428
+ when "remove"
429
+ abort_err("intercept remove: command name required") if ARGV.empty?
430
+ intercept_remove(root, ARGV.first)
431
+ else
432
+ abort_err("intercept: unknown subcommand '#{sub}'. Use: list, add, remove")
433
+ end
434
+
435
+ when "status"
436
+ quiet = ARGV.include?("--quiet")
437
+ ctx = resolve!(quiet: quiet)
438
+ cmd_status(ctx, quiet: quiet)
439
+
440
+ when "exec"
441
+ ctx = resolve!
442
+ cmd_exec(ctx, ARGV)
443
+
444
+ when "run"
445
+ ctx = resolve!
446
+ cmd_run(ctx, ARGV)
447
+
448
+ when "shell"
449
+ ctx = resolve!
450
+ cmd_shell(ctx)
451
+
452
+ when "claude"
453
+ ctx = resolve!
454
+ cmd_claude(ctx, ARGV)
455
+
456
+ when "code"
457
+ ctx = resolve!
458
+ cmd_code(ctx, ARGV.first)
459
+
460
+ when "up"
461
+ ctx = resolve!(quiet: true) rescue nil
462
+ if ctx
463
+ cmd_compose(ctx, ["up", "-d", *ARGV])
464
+ else
465
+ exec("docker", "compose", "up", "-d", *ARGV)
466
+ end
467
+
468
+ when "down"
469
+ ctx = resolve!
470
+ cmd_compose(ctx, ["down", *ARGV])
471
+
472
+ when "logs"
473
+ ctx = resolve!
474
+ cmd_compose(ctx, ["logs", "-f", *ARGV])
475
+
476
+ when "version", "--version", "-v"
477
+ puts "dkit #{VERSION}"
478
+
479
+ when nil, "help", "--help", "-h"
480
+ cmd_help
481
+
482
+ else
483
+ warn "dkit: unknown command '#{command}'. Run 'dkit help'."
484
+ exit 1
485
+ end
metadata ADDED
@@ -0,0 +1,46 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dkit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Augusto Stroligo
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-13 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Routes shell commands transparently into a running devcontainer with
14
+ shell hook integration, per-project intercept lists, VS Code attachment, and docker
15
+ compose helpers.
16
+ email:
17
+ executables:
18
+ - dkit
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - bin/dkit
23
+ homepage:
24
+ licenses:
25
+ - MIT
26
+ metadata: {}
27
+ post_install_message:
28
+ rdoc_options: []
29
+ require_paths:
30
+ - lib
31
+ required_ruby_version: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 2.7.0
36
+ required_rubygems_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ requirements: []
42
+ rubygems_version: 3.5.22
43
+ signing_key:
44
+ specification_version: 4
45
+ summary: 'DevKit CLI: routes shell commands into a running devcontainer'
46
+ test_files: []