v0.38.1 969e52a

extcmd.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"io"
9	"os"
10	"os/exec"
11	"path/filepath"
12	"runtime"
13	"sort"
14	"strings"
15	"time"
16
17	"github.com/bnema/zut/packages/agent/extensions"
18	"github.com/bnema/zut/packages/agent/extproto"
19	"github.com/bnema/zut/packages/ignore"
20)
21
22// runExtCommand dispatches `zut ext ...` subcommands. Returns
23// (handled=true, err) if rawArgs starts with "ext"; otherwise
24// (handled=false, nil) so the main router falls through to the
25// regular flag parser.
26func runExtCommand(ctx context.Context, rawArgs []string, version string) (handled bool, err error) {
27	if len(rawArgs) == 0 || rawArgs[0] != "ext" {
28		return false, nil
29	}
30	if len(rawArgs) == 1 {
31		printExtHelp()
32		return true, nil
33	}
34	switch rawArgs[1] {
35	case "list":
36		return true, extList()
37	case "doctor":
38		return true, extDoctor(version)
39	case "logs":
40		return true, extLogs(rawArgs[2:])
41	case "enable":
42		return true, extToggle(rawArgs[2:], true)
43	case "disable":
44		return true, extToggle(rawArgs[2:], false)
45	case "remove", "rm":
46		return true, extRemove(rawArgs[2:])
47	case "install":
48		return true, extInstallContext(ctx, rawArgs[2:])
49	case "help", "-h", "--help":
50		printExtHelp()
51		return true, nil
52	default:
53		printExtHelp()
54		return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1])
55	}
56}
57
58func printExtHelp() {
59	fmt.Fprintln(os.Stderr, `zut ext — manage extensions
60
61usage:
62  zut ext list                    list installed extensions and their state
63  zut ext doctor                  diagnose installed extensions
64  zut ext logs <name> [-f]        cat / tail an extension's stderr log
65  zut ext enable <name>           re-enable a disabled extension
66  zut ext disable <name>          disable without removing
67  zut ext remove <name>                    delete an extension directory
68  zut ext install [--build=go] <path|git-url>
69                                         copy / clone and validate an extension
70                                         --build=go explicitly builds a local Go extension
71
72extensions live under:
73  $ZUT_HOME/extensions/<name>/extension.json   (global)
74  ./.zut/extensions/<name>/extension.json      (project-local)`)
75}
76
77// extList walks both the global and project-local extension dirs and
78// prints a one-row-per-extension table.
79func extList() error {
80	type row struct {
81		Scope    string
82		Name     string
83		Version  string
84		Enabled  string
85		Language string
86		Dir      string
87	}
88	var rows []row
89	for scope, dir := range extensionDirs() {
90		entries, err := os.ReadDir(dir)
91		if err != nil {
92			continue
93		}
94		for _, e := range entries {
95			if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
96				continue
97			}
98			extDir := filepath.Join(dir, e.Name())
99			mfPath := filepath.Join(extDir, "extension.json")
100			raw, err := os.ReadFile(mfPath)
101			if err != nil {
102				continue
103			}
104			var m struct {
105				Name     string `json:"name"`
106				Version  string `json:"version"`
107				Language string `json:"language"`
108				Enabled  *bool  `json:"enabled"`
109			}
110			if err := json.Unmarshal(raw, &m); err != nil {
111				continue
112			}
113			enabled := "yes"
114			if m.Enabled != nil && !*m.Enabled {
115				enabled = "no"
116			}
117			rows = append(rows, row{
118				Scope: scope, Name: m.Name, Version: m.Version,
119				Enabled: enabled, Language: m.Language, Dir: extDir,
120			})
121		}
122	}
123	if len(rows) == 0 {
124		fmt.Fprintln(os.Stderr, "no extensions installed")
125		fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zut ext install <path|url>`")
126		return nil
127	}
128	fmt.Printf("%-12s  %-20s  %-10s  %-8s  %-10s  %s\n", "scope", "name", "version", "enabled", "language", "dir")
129	for _, r := range rows {
130		fmt.Printf("%-12s  %-20s  %-10s  %-8s  %-10s  %s\n",
131			r.Scope, r.Name, dashIfEmpty(r.Version),
132			r.Enabled, dashIfEmpty(r.Language), r.Dir)
133	}
134	return nil
135}
136
137type extDoctorHooks struct{}
138
139func (extDoctorHooks) Notify(string, string, string)                        {}
140func (extDoctorHooks) Alert(string, extproto.AlertRequest)                  {}
141func (extDoctorHooks) Submit(string)                                        {}
142func (extDoctorHooks) SubmitSlash(string)                                   {}
143func (extDoctorHooks) Insert(string)                                        {}
144func (extDoctorHooks) Display(string, string)                               {}
145func (extDoctorHooks) ClearNotes(string)                                    {}
146func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec)                 {}
147func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {}
148func (extDoctorHooks) ClosePanel(string, string)                            {}
149func (extDoctorHooks) SetStatus(string, string, string, string)             {}
150func (extDoctorHooks) SetWidget(string, string, string, string, []string)   {}
151func (extDoctorHooks) ClearWidget(string, string)                           {}
152
153type extDoctorStaticRow struct {
154	Scope    string
155	Name     string
156	Version  string
157	Enabled  bool
158	Dir      string
159	Exec     string
160	Theme    bool
161	Manifest string
162	Error    string
163	Shadowed bool
164}
165
166// extDoctor diagnoses extension discovery and registration without changing
167// normal fail-soft extension behavior.
168func extDoctor(version string) error {
169	rows := scanExtDoctorStatic()
170	if len(rows) == 0 {
171		fmt.Fprintln(os.Stdout, "no extensions installed")
172		fmt.Fprintln(os.Stdout, "see docs/extensions.md to write your own, or `zut ext install <path|url>`")
173		return nil
174	}
175
176	cwd, _ := os.Getwd()
177	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
178	defer cancel()
179	mgr := extensions.New(ZutHome(), cwd, version, "", "", extDoctorHooks{})
180	errs := mgr.Discover(ctx)
181	mgr.WaitForReady(3 * time.Second)
182	diags := mgr.Diagnostics()
183	mgr.Stop(500 * time.Millisecond)
184
185	diagByDir := map[string]extensions.ExtensionDiagnostic{}
186	for _, d := range diags {
187		diagByDir[d.Dir] = d
188	}
189
190	fmt.Fprintln(os.Stdout, "zut extension doctor")
191	fmt.Fprintln(os.Stdout)
192	for _, row := range rows {
193		printExtDoctorRow(os.Stdout, row, diagByDir[row.Dir])
194	}
195	if len(errs) > 0 {
196		fmt.Fprintln(os.Stdout, "load errors:")
197		for _, err := range errs {
198			// Multiline diagnostics keep their line breaks; indent
199			// continuations so they read as one entry.
200			indented := strings.ReplaceAll(err.Error(), "\n", "\n    ")
201			fmt.Fprintf(os.Stdout, "  ! %s\n", indented)
202		}
203	}
204	return nil
205}
206
207func scanExtDoctorStatic() []extDoctorStaticRow {
208	type scanDir struct {
209		scope string
210		dir   string
211	}
212	var dirs []scanDir
213	if cwd, err := os.Getwd(); err == nil {
214		dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zut", "extensions")})
215	}
216	if h := ZutHome(); h != "" {
217		dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")})
218	}
219
220	seen := map[string]bool{}
221	var rows []extDoctorStaticRow
222	for _, sd := range dirs {
223		entries, err := os.ReadDir(sd.dir)
224		if err != nil {
225			continue
226		}
227		sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
228		for _, e := range entries {
229			if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
230				continue
231			}
232			extDir := filepath.Join(sd.dir, e.Name())
233			row := extDoctorStaticRow{
234				Scope:    sd.scope,
235				Name:     e.Name(),
236				Enabled:  true,
237				Dir:      extDir,
238				Manifest: filepath.Join(extDir, "extension.json"),
239				Theme:    extensions.HasExtensionTheme(extDir),
240			}
241			if seen[e.Name()] {
242				row.Shadowed = true
243			} else {
244				seen[e.Name()] = true
245			}
246			raw, err := os.ReadFile(row.Manifest)
247			if err != nil {
248				row.Error = "missing extension.json"
249				rows = append(rows, row)
250				continue
251			}
252			var m struct {
253				Name    string `json:"name"`
254				Version string `json:"version"`
255				Exec    string `json:"exec"`
256				Enabled *bool  `json:"enabled"`
257			}
258			if err := json.Unmarshal(raw, &m); err != nil {
259				row.Error = "parse manifest: " + err.Error()
260				rows = append(rows, row)
261				continue
262			}
263			if m.Name != "" {
264				row.Name = m.Name
265			} else {
266				row.Error = "manifest: name is required"
267			}
268			row.Version = m.Version
269			row.Exec = m.Exec
270			if m.Enabled != nil {
271				row.Enabled = *m.Enabled
272			}
273			if row.Exec == "" && !row.Theme && row.Error == "" {
274				row.Error = "manifest: exec is required"
275			}
276			rows = append(rows, row)
277		}
278	}
279	return rows
280}
281
282func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) {
283	status := "ok"
284	switch {
285	case row.Error != "":
286		status = "error"
287	case row.Shadowed:
288		status = "shadowed"
289	case !row.Enabled:
290		status = "disabled"
291	case row.Exec == "" && row.Theme:
292		status = "theme-only"
293	case diag.Name == "":
294		status = "not loaded"
295	case diag.ReadyTimedOut:
296		status = "ready-timeout"
297	case diag.AutoReady:
298		status = "auto-ready"
299	case !diag.Ready:
300		status = "not ready"
301	case len(diag.Messages) > 0:
302		status = "warning"
303	}
304	fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir)
305	if row.Version != "" {
306		fmt.Fprintf(w, "  version: %s\n", row.Version)
307	}
308	if row.Error != "" {
309		if row.Exec != "" {
310			fmt.Fprintf(w, "  log: %s\n", extDoctorLogPath(row.Name))
311		}
312		fmt.Fprintf(w, "  error: %s\n", row.Error)
313		return
314	}
315	if row.Shadowed {
316		fmt.Fprintln(w, "  note: skipped because a higher-priority extension directory with this name wins")
317		return
318	}
319	if !row.Enabled {
320		fmt.Fprintln(w, "  note: disabled in extension.json")
321		return
322	}
323	if row.Exec == "" && row.Theme {
324		fmt.Fprintln(w, "  type: theme-only")
325		return
326	}
327	if row.Exec != "" {
328		fmt.Fprintf(w, "  exec: %s\n", row.Exec)
329	}
330	logPath := diag.LogPath
331	if logPath == "" && row.Exec != "" {
332		logPath = extDoctorLogPath(row.Name)
333	}
334	if logPath != "" {
335		fmt.Fprintf(w, "  log: %s\n", logPath)
336	}
337	if len(diag.Commands) > 0 {
338		fmt.Fprintln(w, "  commands:")
339		for _, c := range diag.Commands {
340			state := "active"
341			if !c.Active {
342				state = "shadowed"
343			}
344			fmt.Fprintf(w, "    /%s (%s)\n", c.Name, state)
345		}
346	}
347	if len(diag.Tools) > 0 {
348		fmt.Fprintln(w, "  tools:")
349		for _, t := range diag.Tools {
350			state := "active"
351			if !t.Active {
352				state = "shadowed"
353			}
354			fmt.Fprintf(w, "    %s (%s)\n", t.Name, state)
355		}
356	}
357	for _, msg := range diag.Messages {
358		fmt.Fprintf(w, "  warning: %s\n", msg)
359	}
360}
361
362func extDoctorLogPath(name string) string {
363	if name == "" || ZutHome() == "" {
364		return ""
365	}
366	return filepath.Join(ZutHome(), "logs", "ext-"+name+".log")
367}
368
369// extLogs locates the named extension's log file and either cats or
370// tails it (-f).
371func extLogs(args []string) error {
372	if len(args) == 0 {
373		return fmt.Errorf("usage: zut ext logs <name> [-f]")
374	}
375	name := args[0]
376	follow := false
377	for _, a := range args[1:] {
378		if a == "-f" || a == "--follow" {
379			follow = true
380		}
381	}
382	logPath := filepath.Join(ZutHome(), "logs", "ext-"+name+".log")
383	if _, err := os.Stat(logPath); err != nil {
384		return fmt.Errorf("no log for %q at %s", name, logPath)
385	}
386	if !follow {
387		f, err := os.Open(logPath)
388		if err != nil {
389			return err
390		}
391		defer f.Close()
392		_, err = io.Copy(os.Stdout, f)
393		return err
394	}
395	cmd := exec.Command("tail", "-F", logPath)
396	cmd.Stdout = os.Stdout
397	cmd.Stderr = os.Stderr
398	return cmd.Run()
399}
400
401// extToggle flips the enabled flag in an extension's manifest.
402func extToggle(args []string, enabled bool) error {
403	if len(args) == 0 {
404		verb := "enable"
405		if !enabled {
406			verb = "disable"
407		}
408		return fmt.Errorf("usage: zut ext %s <name>", verb)
409	}
410	name := args[0]
411	dir, err := findExtensionDir(name)
412	if err != nil {
413		return err
414	}
415	mfPath := filepath.Join(dir, "extension.json")
416	raw, err := os.ReadFile(mfPath)
417	if err != nil {
418		return err
419	}
420	var generic map[string]any
421	if err := json.Unmarshal(raw, &generic); err != nil {
422		return fmt.Errorf("parse manifest: %w", err)
423	}
424	generic["enabled"] = enabled
425	out, err := json.MarshalIndent(generic, "", "  ")
426	if err != nil {
427		return err
428	}
429	if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil {
430		return err
431	}
432	state := "enabled"
433	if !enabled {
434		state = "disabled"
435	}
436	fmt.Fprintf(os.Stderr, "%s %s\n", state, name)
437	return nil
438}
439
440// extRemove deletes an extension's directory after a confirmation
441// prompt (skip with --yes).
442func extRemove(args []string) error {
443	if len(args) == 0 {
444		return fmt.Errorf("usage: zut ext remove <name> [--yes]")
445	}
446	name := args[0]
447	yes := false
448	for _, a := range args[1:] {
449		if a == "--yes" || a == "-y" {
450			yes = true
451		}
452	}
453	dir, err := findExtensionDir(name)
454	if err != nil {
455		return err
456	}
457	if !yes {
458		fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir)
459		var resp string
460		_, _ = fmt.Scanln(&resp)
461		if !strings.EqualFold(strings.TrimSpace(resp), "y") {
462			fmt.Fprintln(os.Stderr, "aborted")
463			return nil
464		}
465	}
466	if err := os.RemoveAll(dir); err != nil {
467		return err
468	}
469	fmt.Fprintf(os.Stderr, "removed %s\n", dir)
470	return nil
471}
472
473// extInstall copies a local directory or shallow-clones a git URL
474// into $ZUT_HOME/extensions/. It stages the install and validates the
475// manifest plus any extension-local executable before reporting success.
476// Builds are never inferred or run implicitly; --build=go is an explicit
477// opt-in for local Go extensions.
478func extInstall(args []string) error {
479	return extInstallContext(context.Background(), args)
480}
481
482func extInstallContext(ctx context.Context, args []string) error {
483	opts, err := parseExtInstallArgs(args)
484	if err != nil {
485		return err
486	}
487	src := opts.source
488	dest := filepath.Join(ZutHome(), "extensions")
489	if err := os.MkdirAll(dest, 0o755); err != nil {
490		return err
491	}
492
493	if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
494		if opts.builder != "" {
495			return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there")
496		}
497		return installGitExtension(ctx, src, dest)
498	}
499
500	// Local path: must be a directory containing extension.json.
501	info, err := os.Stat(src)
502	if err != nil {
503		return err
504	}
505	if !info.IsDir() {
506		return fmt.Errorf("not a directory: %s", src)
507	}
508	// Resolve to an absolute, cleaned path before reading the manifest.
509	// This also makes sources like "." safe to install.
510	absSrc, err := filepath.Abs(src)
511	if err != nil {
512		return err
513	}
514	manifest, err := readExtensionManifest(absSrc)
515	if err != nil {
516		return fmt.Errorf("source extension: %w", err)
517	}
518	out := filepath.Join(dest, manifest.Name)
519	if _, err := os.Stat(out); err == nil {
520		return fmt.Errorf("destination %s already exists; remove it first", out)
521	}
522
523	stageRoot, err := os.MkdirTemp(dest, ".zut-extension-install-")
524	if err != nil {
525		return fmt.Errorf("create install staging directory: %w", err)
526	}
527	defer os.RemoveAll(stageRoot)
528	staged := filepath.Join(stageRoot, "extension")
529	if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil {
530		return fmt.Errorf("copy extension: %w", err)
531	}
532	if opts.builder != "" {
533		if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil {
534			return err
535		}
536	}
537	if err := validateExtensionExecutable(staged, manifest); err != nil {
538		return err
539	}
540	if err := os.Rename(staged, out); err != nil {
541		return fmt.Errorf("finalize install: %w", err)
542	}
543	fmt.Fprintf(os.Stderr, "installed %s\n", out)
544	return nil
545}
546
547type extInstallOptions struct {
548	source  string
549	builder string
550}
551
552func parseExtInstallArgs(args []string) (extInstallOptions, error) {
553	if len(args) == 0 {
554		return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
555	}
556
557	var opts extInstallOptions
558	builderSet := false
559	for i := 0; i < len(args); i++ {
560		arg := args[i]
561		switch {
562		case arg == "--build":
563			if i+1 >= len(args) {
564				return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
565			}
566			i++
567			if builderSet {
568				return extInstallOptions{}, errors.New("extension builder specified more than once")
569			}
570			builderSet = true
571			opts.builder = strings.ToLower(strings.TrimSpace(args[i]))
572			if opts.builder == "" {
573				return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
574			}
575		case strings.HasPrefix(arg, "--build="):
576			if builderSet {
577				return extInstallOptions{}, errors.New("extension builder specified more than once")
578			}
579			builderSet = true
580			opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build=")))
581			if opts.builder == "" {
582				return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
583			}
584		case strings.HasPrefix(arg, "-"):
585			return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg)
586		case opts.source != "":
587			return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
588		default:
589			opts.source = arg
590		}
591	}
592	if opts.source == "" {
593		return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
594	}
595	if opts.builder != "" && opts.builder != "go" {
596		return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder)
597	}
598	return opts, nil
599}
600
601func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error {
602	if manifest.Exec == "" {
603		return errors.New("cannot build a theme-only extension; it does not declare an executable")
604	}
605	execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec)
606	if err != nil {
607		return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err)
608	}
609	stagedExec := filepath.Join(stagedDir, execRel)
610	if err := os.MkdirAll(filepath.Dir(stagedExec), 0o755); err != nil {
611		return fmt.Errorf("prepare Go build output: %w", err)
612	}
613
614	fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name)
615	cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".")
616	cmd.Dir = sourceDir
617	cmd.Stdout = os.Stderr
618	cmd.Stderr = os.Stderr
619	if err := cmd.Run(); err != nil {
620		return fmt.Errorf("build extension with go: %w", err)
621	}
622	return nil
623}
624
625func extensionRelativeExecPath(dir, execName string) (string, error) {
626	normalized := filepath.FromSlash(execName)
627	if filepath.IsAbs(normalized) {
628		return "", fmt.Errorf("extension executable %q is absolute", execName)
629	}
630	resolved, local := extensions.ResolveExecPath(dir, execName)
631	if !local {
632		return "", fmt.Errorf("extension executable %q is a PATH command", execName)
633	}
634	rel, err := filepath.Rel(dir, resolved)
635	if err != nil {
636		return "", fmt.Errorf("resolve extension executable %q: %w", execName, err)
637	}
638	if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
639		return "", fmt.Errorf("extension executable %q escapes the extension directory", execName)
640	}
641	return rel, nil
642}
643
644func installGitExtension(ctx context.Context, src, dest string) error {
645	stageRoot, err := os.MkdirTemp(dest, ".zut-extension-clone-")
646	if err != nil {
647		return fmt.Errorf("create install staging directory: %w", err)
648	}
649	defer os.RemoveAll(stageRoot)
650
651	cloneDir := filepath.Join(stageRoot, "extension")
652	cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir)
653	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
654	cmd.Stdout = os.Stderr
655	cmd.Stderr = os.Stderr
656	if err := cmd.Run(); err != nil {
657		return fmt.Errorf("git clone: %w", err)
658	}
659	manifest, err := readExtensionManifest(cloneDir)
660	if err != nil {
661		return fmt.Errorf("cloned extension: %w", err)
662	}
663	out := filepath.Join(dest, manifest.Name)
664	if _, err := os.Stat(out); err == nil {
665		return fmt.Errorf("destination %s already exists; remove it first", out)
666	}
667	if err := validateExtensionExecutable(cloneDir, manifest); err != nil {
668		return err
669	}
670	if err := os.Rename(cloneDir, out); err != nil {
671		return fmt.Errorf("finalize install: %w", err)
672	}
673	fmt.Fprintf(os.Stderr, "installed %s\n", out)
674	return nil
675}
676
677func readExtensionManifest(dir string) (extensions.Manifest, error) {
678	raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
679	if err != nil {
680		if os.IsNotExist(err) {
681			return extensions.Manifest{}, errors.New("source lacks extension.json")
682		}
683		return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err)
684	}
685	var manifest extensions.Manifest
686	if err := json.Unmarshal(raw, &manifest); err != nil {
687		return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err)
688	}
689	if strings.TrimSpace(manifest.Name) == "" {
690		return extensions.Manifest{}, errors.New("manifest: name is required")
691	}
692	if manifest.Name != strings.TrimSpace(manifest.Name) {
693		return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name)
694	}
695	if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) {
696		return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)
697	}
698	if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) {
699		return extensions.Manifest{}, errors.New("manifest: exec is required")
700	}
701	if manifest.Exec != "" {
702		resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
703		if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
704			rel, err := filepath.Rel(dir, resolved)
705			if err != nil {
706				return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err)
707			}
708			if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
709				return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec)
710			}
711		}
712	}
713	return manifest, nil
714}
715
716func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool {
717	required := map[string]bool{"extension.json": true}
718	if manifest.Exec == "" {
719		return required
720	}
721	resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
722	if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
723		return required
724	}
725	rel, err := filepath.Rel(dir, resolved)
726	if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
727		required[filepath.ToSlash(rel)] = true
728	}
729	return required
730}
731
732func validateExtensionExecutable(dir string, manifest extensions.Manifest) error {
733	if manifest.Exec == "" {
734		return nil
735	}
736	path, local := extensions.ResolveExecPath(dir, manifest.Exec)
737	if !local {
738		// Bare commands such as "go", "node", and "npx" are resolved
739		// from PATH when zut starts. They may be intentionally installed
740		// before the runtime is present on the current machine.
741		return nil
742	}
743	info, err := os.Stat(path)
744	if err != nil {
745		if os.IsNotExist(err) {
746			return fmt.Errorf("extension executable %q is missing; provide it before installing. Local Go extensions can opt in with `zut ext install --build=go <path>`", manifest.Exec)
747		}
748		return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err)
749	}
750	if info.IsDir() {
751		return fmt.Errorf("extension executable %q is a directory", manifest.Exec)
752	}
753	if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
754		return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec)
755	}
756	return nil
757}
758
759func extensionDirs() map[string]string {
760	out := map[string]string{}
761	if h := ZutHome(); h != "" {
762		out["global"] = filepath.Join(h, "extensions")
763	}
764	if cwd, err := os.Getwd(); err == nil {
765		out["project"] = filepath.Join(cwd, ".zut", "extensions")
766	}
767	return out
768}
769
770func findExtensionDir(name string) (string, error) {
771	for _, dir := range extensionDirs() {
772		candidate := filepath.Join(dir, name)
773		if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
774			return candidate, nil
775		}
776	}
777	return "", fmt.Errorf("extension %q not found", name)
778}
779
780func dashIfEmpty(s string) string {
781	if s == "" {
782		return "-"
783	}
784	return s
785}
786
787// copyDir does a recursive copy of src to dst preserving file mode
788// bits. Used by `zut ext install <local-path>`.
789//
790// Entries matched by the source's root .gitignore are skipped, and
791// .git itself is always skipped. This keeps non-portable, regeneratable
792// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
793// out of the installed copy so the extension stays functional at its new
794// location.
795func copyDir(src, dst string) error {
796	return copyDirWithRequired(src, dst, nil)
797}
798
799func copyDirWithRequired(src, dst string, required map[string]bool) error {
800	ig := loadGitignore(src)
801	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
802		if err != nil {
803			return err
804		}
805		rel, err := filepath.Rel(src, path)
806		if err != nil {
807			return err
808		}
809		relSlash := filepath.ToSlash(rel)
810		if rel != "." {
811			name := filepath.Base(rel)
812			if info.IsDir() && name == ".git" {
813				return filepath.SkipDir
814			}
815			if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) {
816				if info.IsDir() {
817					return filepath.SkipDir
818				}
819				return nil
820			}
821		}
822		target := filepath.Join(dst, rel)
823		if info.IsDir() {
824			return os.MkdirAll(target, info.Mode())
825		}
826		in, err := os.Open(path)
827		if err != nil {
828			return err
829		}
830		defer in.Close()
831		out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
832		if err != nil {
833			return err
834		}
835		defer out.Close()
836		_, err = io.Copy(out, in)
837		return err
838	})
839}
840
841func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool {
842	if required[rel] {
843		return true
844	}
845	if !isDir {
846		return false
847	}
848	prefix := strings.TrimSuffix(rel, "/") + "/"
849	for path := range required {
850		if strings.HasPrefix(path, prefix) {
851			return true
852		}
853	}
854	return false
855}
856
857// gitignore matching lives in packages/ignore so the @-file picker in
858// packages/agent/modes can share it without an import cycle. These
859// thin aliases keep the existing call sites (and tests) terse.
860type gitignore = ignore.Gitignore
861
862func loadGitignore(root string) *gitignore { return ignore.Load(root) }
863
864func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close