Skip to content

Commit 35d57a3

Browse files
mikeland73claude
andauthored
Add shell aliases feature to devbox.json (#2835)
Add a top-level `aliases` map to devbox.json that lets users define shell aliases declaratively instead of hand-writing them in the init_hook. ```json { "shell": { "init_hook": "echo welcome" }, "aliases": { "ll": "ls -la", "gs": "git status" } } ``` **In the interactive shell** (`devbox shell`), aliases are injected after the init hook is sourced, so they can rely on anything the hook sets up. They use the shell's builtin `alias` command, which is compatible across bash, zsh, and fish. Values are single-quoted and escaped per-shell so they are passed verbatim. **Via `devbox run`**, `devbox run <alias>` expands the alias to its command and runs it (with any extra args passed through), mirroring how a shell expands an alias in command position. This means aliases work without an interactive shell — i.e. even when the init hook hasn't defined them. Aliases merge across included plugins and the parent config, with the parent overriding plugin-defined aliases (same precedence as `env`/`scripts`). - configfile: add top-level `aliases` field + validation (no empty/whitespace names, no empty commands) - config: add merged `Aliases()` accessor (root overrides included) - shell: render alias lines (sorted, escaped) into the shellrc after the init hook - devbox run: expand `devbox run <alias>` to the alias command - shellrc.tmpl / shellrc_fish.tmpl: emit aliases after hooks are sourced - schema: document the top-level `aliases` key - examples/plugins/local: plugin defines aliases, parent overrides one - tests: parsing, merging, plugin override (via the local example), rendering, escaping, validation, and `devbox run <alias>` --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8816d61 commit 35d57a3

12 files changed

Lines changed: 321 additions & 1 deletion

File tree

.schema/devbox.schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,16 @@
129129
},
130130
"additionalProperties": false
131131
},
132+
"aliases": {
133+
"description": "Shell aliases set up when entering the devbox shell, after the init_hook runs. Works in bash, zsh, and fish.",
134+
"type": "object",
135+
"patternProperties": {
136+
".*": {
137+
"description": "The command the alias expands to.",
138+
"type": "string"
139+
}
140+
}
141+
},
132142
"include": {
133143
"description": "List of additional plugins to activate within your devbox shell",
134144
"type": "array",

examples/plugins/local/devbox.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
]
1111
}
1212
},
13+
"aliases": {
14+
"greet": "echo greeting-from-parent"
15+
},
1316
"include": [
1417
"path:my-plugin/plugin.json"
1518
]

examples/plugins/local/my-plugin/plugin.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,11 @@ this is a comment inside the create files
2020
"echo \"ran local plugin init hook\"",
2121
"export MY_INIT_HOOK_VAR=BAR"
2222
]
23+
},
24+
// Aliases defined by the plugin. "greet" is overridden by the parent
25+
// devbox.json, while "plugin_only" is provided solely by this plugin.
26+
"aliases": {
27+
"greet": "echo greeting-from-plugin",
28+
"plugin_only": "echo from-plugin"
2329
}
2430
}

internal/devbox/devbox.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,15 @@ func (d *Devbox) RunScript(ctx context.Context, envOpts devopt.EnvOptions, cmdNa
316316
// which we don't want. So, one solution is to write the entire command and its arguments into the
317317
// file itself, but that may not be great if the variables contain sensitive information. Instead,
318318
// we save the entire command (with args) into the DEVBOX_RUN_CMD var, and then the script evals it.
319+
//
320+
// If cmdName is an alias defined in devbox.json, expand it to its command
321+
// first, mirroring how a shell expands an alias that appears in command
322+
// position. This lets `devbox run <alias>` work without an interactive
323+
// shell (i.e. even when the init hook hasn't defined the alias).
324+
runCmd := cmdName
325+
if alias, ok := d.cfg.Aliases()[cmdName]; ok {
326+
runCmd = alias
327+
}
319328
scriptBody, err := shellgen.ScriptBody(d, "eval $DEVBOX_RUN_CMD\n")
320329
if err != nil {
321330
return err
@@ -326,7 +335,7 @@ func (d *Devbox) RunScript(ctx context.Context, envOpts devopt.EnvOptions, cmdNa
326335
}
327336
script := shellgen.ScriptPath(d.ProjectDir(), arbitraryCmdFilename)
328337
cmdWithArgs = []string{strconv.Quote(script)}
329-
env["DEVBOX_RUN_CMD"] = strings.Join(append([]string{cmdName}, cmdArgs...), " ")
338+
env["DEVBOX_RUN_CMD"] = strings.Join(append([]string{runCmd}, cmdArgs...), " ")
330339
}
331340

332341
return nix.RunScript(d.projectDir, strings.Join(cmdWithArgs, " "), env)

internal/devbox/shell.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os"
1313
"os/exec"
1414
"path/filepath"
15+
"sort"
1516
"strings"
1617
"text/template"
1718
"time"
@@ -325,6 +326,8 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
325326
ExportEnv string
326327
ShellName string
327328

329+
ShellAliases []string
330+
328331
RefreshAliasName string
329332
RefreshCmd string
330333
RefreshAliasEnvVar string
@@ -337,6 +340,7 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
337340
HistoryFile: strings.TrimSpace(s.historyFile),
338341
ExportEnv: exportify(s.env),
339342
ShellName: string(s.name),
343+
ShellAliases: s.aliasLines(),
340344
RefreshAliasName: s.devbox.refreshAliasName(),
341345
RefreshCmd: s.devbox.refreshCmd(),
342346
RefreshAliasEnvVar: s.devbox.refreshAliasEnvVar(),
@@ -349,6 +353,48 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
349353
return path, nil
350354
}
351355

356+
// aliasLines returns the shell `alias` commands for the aliases defined in
357+
// devbox.json, sorted by alias name for deterministic output. The returned
358+
// lines use the current shell's builtin `alias` command (which is compatible
359+
// across bash, zsh, and fish) and are injected into the shellrc after the init
360+
// hook is sourced. It returns nil when there are no aliases or no config.
361+
func (s *DevboxShell) aliasLines() []string {
362+
if s.devbox == nil || s.devbox.Config() == nil {
363+
return nil
364+
}
365+
aliases := s.devbox.Config().Aliases()
366+
if len(aliases) == 0 {
367+
return nil
368+
}
369+
370+
names := make([]string, 0, len(aliases))
371+
for name := range aliases {
372+
names = append(names, name)
373+
}
374+
sort.Strings(names)
375+
376+
lines := make([]string, 0, len(names))
377+
for _, name := range names {
378+
lines = append(lines, fmt.Sprintf("alias %s=%s", name, s.quoteAliasValue(aliases[name])))
379+
}
380+
return lines
381+
}
382+
383+
// quoteAliasValue single-quotes an alias value so it is passed verbatim to the
384+
// shell's `alias` builtin, escaping any embedded quotes for the current shell.
385+
func (s *DevboxShell) quoteAliasValue(value string) string {
386+
if s.name == shFish {
387+
// Inside fish single quotes, only \\ and \' are escape sequences.
388+
value = strings.ReplaceAll(value, `\`, `\\`)
389+
value = strings.ReplaceAll(value, `'`, `\'`)
390+
return "'" + value + "'"
391+
}
392+
// POSIX shells (bash/zsh/ksh): close the quote, emit an escaped quote,
393+
// then reopen, i.e. ' -> '\''.
394+
value = strings.ReplaceAll(value, `'`, `'\''`)
395+
return "'" + value + "'"
396+
}
397+
352398
// setupShellStartupFiles creates initialization files for the shell by sourcing the user's originals.
353399
// We do this instead of linking or copying, so that we can set correct ZDOTDIR when sourcing
354400
// user's config files which may use the ZDOTDIR env var inside them.

internal/devbox/shell_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/google/go-cmp/cmp"
1717
"go.jetify.com/devbox/internal/devbox/devopt"
18+
"go.jetify.com/devbox/internal/devconfig"
1819
"go.jetify.com/devbox/internal/envir"
1920
"go.jetify.com/devbox/internal/shellgen"
2021
"go.jetify.com/devbox/internal/xdg"
@@ -113,6 +114,86 @@ If the new shellrc is correct, you can update the golden file with:
113114
}
114115
}
115116

117+
func TestWriteDevboxShellrcAliases(t *testing.T) {
118+
cfgJSON := `{
119+
"shell": {
120+
"init_hook": "echo hi"
121+
},
122+
"aliases": {
123+
"ll": "ls -la",
124+
"gs": "git status",
125+
"say": "echo it's here"
126+
}
127+
}`
128+
dir := t.TempDir()
129+
if err := os.WriteFile(
130+
filepath.Join(dir, "devbox.json"), []byte(cfgJSON), 0o644,
131+
); err != nil {
132+
t.Fatal(err)
133+
}
134+
cfg, err := devconfig.Open(dir)
135+
if err != nil {
136+
t.Fatalf("Open config error: %v", err)
137+
}
138+
139+
tests := []struct {
140+
shell name
141+
want []string
142+
}{
143+
{
144+
shell: shBash,
145+
want: []string{
146+
`alias gs='git status'`,
147+
`alias ll='ls -la'`,
148+
`alias say='echo it'\''s here'`,
149+
},
150+
},
151+
{
152+
shell: shFish,
153+
want: []string{
154+
`alias gs='git status'`,
155+
`alias ll='ls -la'`,
156+
`alias say='echo it\'s here'`,
157+
},
158+
},
159+
}
160+
161+
for _, test := range tests {
162+
t.Run(string(test.shell), func(t *testing.T) {
163+
s := &DevboxShell{
164+
devbox: &Devbox{projectDir: dir, cfg: cfg},
165+
projectDir: dir,
166+
name: test.shell,
167+
}
168+
path, err := s.writeDevboxShellrc()
169+
if err != nil {
170+
t.Fatalf("writeDevboxShellrc error: %v", err)
171+
}
172+
b, err := os.ReadFile(path)
173+
if err != nil {
174+
t.Fatal(err)
175+
}
176+
got := string(b)
177+
178+
// Aliases must appear after the init hook is sourced.
179+
hookIdx := strings.Index(got, ".hooks")
180+
if hookIdx < 0 {
181+
t.Fatalf("hooks not sourced in shellrc:\n%s", got)
182+
}
183+
for _, line := range test.want {
184+
idx := strings.Index(got, line)
185+
if idx < 0 {
186+
t.Errorf("expected alias line %q in shellrc:\n%s", line, got)
187+
continue
188+
}
189+
if idx < hookIdx {
190+
t.Errorf("alias %q injected before init hook was sourced", line)
191+
}
192+
}
193+
})
194+
}
195+
}
196+
116197
func TestShellPath(t *testing.T) {
117198
tests := []struct {
118199
name string

internal/devbox/shellrc.tmpl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ cd "{{ .ProjectDir }}" || exit
6969

7070
cd "$working_dir" || exit
7171

72+
{{- if .ShellAliases }}
73+
74+
# Set aliases defined in devbox.json. These run after the init hook so they can
75+
# rely on anything the hook sets up.
76+
{{ range .ShellAliases }}{{ . }}
77+
{{ end -}}
78+
{{- end }}
79+
7280
{{- if .ShellStartTime }}
7381
# log that the shell is interactive now!
7482
devbox log shell-interactive {{ .ShellStartTime }}

internal/devbox/shellrc_fish.tmpl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ source "{{ .HooksFilePath }}"
6363

6464
cd "$workingDir" || exit
6565

66+
{{- if .ShellAliases }}
67+
68+
# Set aliases defined in devbox.json. These run after the init hook so they can
69+
# rely on anything the hook sets up.
70+
{{ range .ShellAliases }}{{ . }}
71+
{{ end -}}
72+
{{- end }}
73+
6674
{{- if .ShellStartTime }}
6775
# log that the shell is interactive now!
6876
devbox log shell-interactive {{ .ShellStartTime }}

internal/devconfig/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,18 @@ func (c *Config) InitHook() *shellcmd.Commands {
368368
return &commands
369369
}
370370

371+
// Aliases returns the merged shell aliases from this config and any included
372+
// configs (plugins). Aliases defined in the root config take precedence over
373+
// those from included configs.
374+
func (c *Config) Aliases() map[string]string {
375+
aliases := map[string]string{}
376+
for _, i := range c.included {
377+
maps.Copy(aliases, i.Aliases())
378+
}
379+
maps.Copy(aliases, c.Root.Aliases)
380+
return aliases
381+
}
382+
371383
func (c *Config) Scripts() configfile.Scripts {
372384
scripts := configfile.Scripts{}
373385
for _, i := range c.included {

internal/devconfig/config_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,106 @@ func mkNestedDirs(t *testing.T) (root, child, nested string) {
297297
return root, child, nested
298298
}
299299

300+
func TestAliases(t *testing.T) {
301+
dir := t.TempDir()
302+
cfgJSON := `{
303+
"shell": {
304+
"init_hook": "echo hi"
305+
},
306+
"aliases": {
307+
"ll": "ls -la",
308+
"gs": "git status"
309+
}
310+
}`
311+
if err := os.WriteFile(filepath.Join(dir, configfile.DefaultName), []byte(cfgJSON), 0o644); err != nil {
312+
t.Fatal(err)
313+
}
314+
cfg, err := Open(dir)
315+
if err != nil {
316+
t.Fatalf("Open error: %v", err)
317+
}
318+
got := cfg.Aliases()
319+
want := map[string]string{"ll": "ls -la", "gs": "git status"}
320+
if diff := cmp.Diff(want, got); diff != "" {
321+
t.Errorf("Aliases() mismatch (-want +got):\n%s", diff)
322+
}
323+
}
324+
325+
func TestAliasesEmpty(t *testing.T) {
326+
dir := t.TempDir()
327+
if err := os.WriteFile(
328+
filepath.Join(dir, configfile.DefaultName),
329+
[]byte(`{"shell": {"init_hook": "echo hi"}}`),
330+
0o644,
331+
); err != nil {
332+
t.Fatal(err)
333+
}
334+
cfg, err := Open(dir)
335+
if err != nil {
336+
t.Fatalf("Open error: %v", err)
337+
}
338+
if got := cfg.Aliases(); len(got) != 0 {
339+
t.Errorf("Aliases() = %v, want empty", got)
340+
}
341+
}
342+
343+
func TestAliasesInvalid(t *testing.T) {
344+
tests := map[string]string{
345+
"empty name": `{"aliases": {"": "ls -la"}}`,
346+
"whitespace name": `{"aliases": {"bad name": "ls -la"}}`,
347+
"empty command": `{"aliases": {"ll": ""}}`,
348+
"whitespace command": `{"aliases": {"ll": " "}}`,
349+
}
350+
for name, cfgJSON := range tests {
351+
t.Run(name, func(t *testing.T) {
352+
dir := t.TempDir()
353+
if err := os.WriteFile(
354+
filepath.Join(dir, configfile.DefaultName), []byte(cfgJSON), 0o644,
355+
); err != nil {
356+
t.Fatal(err)
357+
}
358+
if _, err := Open(dir); err == nil {
359+
t.Errorf("Open(%q) succeeded, want validation error", cfgJSON)
360+
}
361+
})
362+
}
363+
}
364+
365+
// TestExampleLocalPluginAliases loads the examples/plugins/local project and
366+
// verifies that aliases defined in the plugin are merged into the parent
367+
// config, and that an alias defined in the parent overrides the plugin's.
368+
func TestExampleLocalPluginAliases(t *testing.T) {
369+
exampleDir, err := filepath.Abs(
370+
filepath.Join("..", "..", "examples", "plugins", "local"),
371+
)
372+
if err != nil {
373+
t.Fatal(err)
374+
}
375+
376+
cfg, err := Open(exampleDir)
377+
if err != nil {
378+
t.Fatalf("Open(%q) error: %v", exampleDir, err)
379+
}
380+
381+
lockfile, err := lock.GetFile(&testLockProject{dir: exampleDir})
382+
if err != nil {
383+
t.Fatalf("lock.GetFile error: %v", err)
384+
}
385+
if err := cfg.LoadRecursive(lockfile); err != nil {
386+
t.Fatalf("LoadRecursive error: %v", err)
387+
}
388+
389+
want := map[string]string{
390+
// Parent devbox.json overrides the plugin's "greet" alias.
391+
"greet": "echo greeting-from-parent",
392+
// "plugin_only" is contributed solely by the plugin.
393+
"plugin_only": "echo from-plugin",
394+
}
395+
if diff := cmp.Diff(want, cfg.Aliases()); diff != "" {
396+
t.Errorf("Aliases() mismatch (-want +got):\n%s", diff)
397+
}
398+
}
399+
300400
func TestDefault(t *testing.T) {
301401
path := filepath.Join(t.TempDir())
302402
cfg := DefaultConfig()

0 commit comments

Comments
 (0)