aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2026-01-06 20:02:15 +0100
committerMartin Fischer <martin@push-f.com>2026-01-06 22:39:30 +0100
commit222b4dc884892d5927dabbab2b2b6e64d3244002 (patch)
tree18b6ce665dfcc99996411b553631484cd3a88cad
initial commit
-rw-r--r--.gitignore1
-rw-r--r--LICENSE19
-rw-r--r--README.md18
-rw-r--r--default.nix20
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--hashes.nix3
-rw-r--r--programs/open/main.go121
-rw-r--r--programs/serve/main.go30
-rwxr-xr-xscripts/reset-bg4
-rwxr-xr-xscripts/set-bg5
-rwxr-xr-xscripts/sreplace17
12 files changed, 245 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c4a847d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/result
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b8b8894
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 Martin Fischer <martin@push-f.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..68116c6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,18 @@
+# CMD 50
+
+CMD 50 is a collection of perhaps someday 50 command-line tools.
+
+* open - open the given URI or file with your configured application
+* reset-bg - reset background of [foot] terminal
+* serve - serve the current directory over HTTP
+* set-bg - set background of [foot] terminal
+* sreplace - OpenSSH secure replace a directory
+
+TODO: write man pages
+
+## Other recommendations
+
+* wl-copy and wl-paste from [wl-clipboard]
+
+[foot]: https://codeberg.org/dnkl/foot
+[wl-clipboard]: https://github.com/bugaevc/wl-clipboard
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..407458a
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,20 @@
+{ pkgs ? import <nixpkgs> {} }:
+
+let
+ go_module = pkgs.buildGoModule {
+ name = "cmd50";
+ src = pkgs.lib.cleanSource ./.;
+ vendorHash = (import ./hashes.nix).go;
+ };
+in
+pkgs.stdenv.mkDerivation {
+ name = "cmd50";
+
+ src = ./scripts;
+
+ installPhase = ''
+ mkdir -p $out/bin
+ cp $src/* $out/bin/
+ cp ${go_module}/bin/* $out/bin/
+ '';
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..1bbc9da
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module push-f.com/cmd50
+
+go 1.25.5
+
+require github.com/BurntSushi/toml v1.6.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f74b269
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
diff --git a/hashes.nix b/hashes.nix
new file mode 100644
index 0000000..3196db1
--- /dev/null
+++ b/hashes.nix
@@ -0,0 +1,3 @@
+{
+ go = "sha256-pbA/AlBz3cQYRTMnQ/qBPcinYOKokrBLNhkbRTq54gE=";
+}
diff --git a/programs/open/main.go b/programs/open/main.go
new file mode 100644
index 0000000..415f62a
--- /dev/null
+++ b/programs/open/main.go
@@ -0,0 +1,121 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "mime"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+)
+
+type Config struct {
+ MimeTypes map[string][]string `toml:"mime-types"`
+ UriSchemes map[string][]string `toml:"uri-schemes"`
+}
+
+func main() {
+ if len(os.Args) != 2 {
+ fmt.Fprintf(os.Stderr, "usage: %s <file-or-uri>", os.Args[0])
+ os.Exit(1)
+ }
+
+ arg := os.Args[1]
+
+ configPath, err := xdgConfigPath("open")
+ if err != nil {
+ fatal("failed to find config path: %v", err)
+ }
+
+ var cfg Config
+ meta, err := toml.DecodeFile(configPath, &cfg)
+ if err != nil {
+ fatal("failed to decode config: %v", err)
+ }
+ if len(meta.Undecoded()) != 0 {
+ fatal("unknown config keys: %v", meta.Undecoded())
+ }
+
+ for scheme, cmd := range cfg.UriSchemes {
+ if strings.HasPrefix(arg, scheme+":") {
+ cmd = append(cmd, arg)
+ err := run(cmd)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return
+ }
+ }
+
+ stat, err := os.Stat(arg)
+ if err != nil {
+ if os.IsNotExist(err) && strings.Contains(arg, ":") {
+ fatal("file not found or URI scheme not registered")
+ }
+ fatal("stat failed: %v", err)
+ }
+
+ var mimeType string
+
+ if stat.IsDir() {
+ mimeType = "inode/directory"
+ } else {
+ ext := path.Ext(arg)
+ mimeType = mime.TypeByExtension(ext)
+ if mimeType == "" {
+ fatal("no associated mime type for file extension %s", ext)
+ }
+ // strip e.g. `; charset=utf-8`
+ mimeType, _, _ = strings.Cut(mimeType, ";")
+ }
+
+ for mimeTypePat, cmd := range cfg.MimeTypes {
+ matched, err := path.Match(mimeTypePat, mimeType)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if matched {
+ cmd = append(cmd, arg)
+ err := run(cmd)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return
+ }
+ }
+
+ fatal("no opener configured for mime %s", mimeType)
+}
+
+func fatal(msg string, v ...any) {
+ fmt.Fprintf(os.Stderr, "fatal: "+msg+"\n", v...)
+ os.Exit(1)
+}
+
+func run(args []string) error {
+ cmd := exec.Command(args[0], args[1:]...)
+
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ return cmd.Run()
+}
+
+func xdgConfigPath(appName string) (string, error) {
+ xdgHome := os.Getenv("XDG_CONFIG_HOME")
+
+ if xdgHome == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ xdgHome = filepath.Join(home, ".config")
+ }
+
+ return filepath.Join(xdgHome, appName, "config.toml"), nil
+}
diff --git a/programs/serve/main.go b/programs/serve/main.go
new file mode 100644
index 0000000..c0de682
--- /dev/null
+++ b/programs/serve/main.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "log/slog"
+ "net/http"
+ "os"
+)
+
+var port = flag.Int("port", 8000, "port")
+
+func main() {
+ flag.Parse()
+
+ logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+ slog.SetDefault(logger)
+
+ slog.Info(fmt.Sprintf("listening on http://localhost:%d/", *port))
+ err := http.ListenAndServe(fmt.Sprintf(":%d", *port), logRequest(http.FileServer(http.Dir("."))))
+ log.Fatal(err)
+}
+
+func logRequest(handler http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ slog.Info("request", "addr", r.RemoteAddr, "method", r.Method, "url", r.URL)
+ handler.ServeHTTP(w, r)
+ })
+}
diff --git a/scripts/reset-bg b/scripts/reset-bg
new file mode 100755
index 0000000..049954d
--- /dev/null
+++ b/scripts/reset-bg
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# Resets the background color of the current foot terminal to the default.
+
+echo -en "\033]111\007"
diff --git a/scripts/set-bg b/scripts/set-bg
new file mode 100755
index 0000000..2ed0a78
--- /dev/null
+++ b/scripts/set-bg
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+# Sets the background color of the current foot terminal to the given hex color.
+
+rgb_with_slashes=$(echo "$1" | sed -r 's,(..)(..),\1/\2/,')
+echo -en "\033]11;rgb:$rgb_with_slashes\007"
diff --git a/scripts/sreplace b/scripts/sreplace
new file mode 100755
index 0000000..f90a4ae
--- /dev/null
+++ b/scripts/sreplace
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Replaces the contents of a remote directory with the contents of a local directory.
+
+if [ "$#" -ne 3 ]; then
+ echo "usage: $0 <local_dir> <ssh_dest> <remote_dir>"
+ exit 1
+fi
+printf -v remote_dir %q $3
+
+cd $1
+tar cf - . | ssh $2 "set -xe
+TEMP_DIR=\$(mktemp -d)
+cd \$TEMP_DIR
+tar xvf -
+rm -rf $remote_dir
+mv \$TEMP_DIR $remote_dir
+"