dkit 0.4.1 → 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 +4 -4
- data/CHANGELOG.md +17 -0
- data/bin/dkit +2 -558
- data/lib/dkit/commands.rb +229 -0
- data/lib/dkit/container.rb +71 -0
- data/lib/dkit/context.rb +44 -0
- data/lib/dkit/intercept.rb +48 -0
- data/lib/dkit/project.rb +26 -0
- data/lib/dkit/shell_hook.rb +137 -0
- data/lib/dkit/version.rb +3 -0
- data/lib/dkit.rb +10 -0
- metadata +15 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d15099fc4205be2847823c14188c97dba782e323b71a804ff3c5bef99cb168f3
|
|
4
|
+
data.tar.gz: e63c6b54a4c12bd3a59cfe16d86a499d220e37d3c10874a33dc04fcd2722aa50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11a9ad7e663c787c54b973666fdbcaa6ed87285a1ab855def63984b443e772bc890dacc2301486f7bd2af23acd41599448b217f6b172388700a7060b16325966
|
|
7
|
+
data.tar.gz: 91eacc0bbac112b98168ed8eee150e16ccf22ee8c6df5b83eb0b3cd6bd5fa66d2aa182c17dc091c02a4e3e8f842ac506052568af995369be3e4a5a83a83d98d0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
|
|
8
25
|
## [0.4.1] - 2026-04-13
|
|
9
26
|
|
|
10
27
|
### Fixed
|
data/bin/dkit
CHANGED
|
@@ -1,559 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
-
|
|
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.1"
|
|
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
|
-
system("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
|
data/lib/dkit/context.rb
ADDED
|
@@ -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
|
data/lib/dkit/project.rb
ADDED
|
@@ -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
|
data/lib/dkit/version.rb
ADDED
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
|
+
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-
|
|
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.
|
|
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: []
|