summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-22 19:17:57 +0200
committerPaul Buetow <paul@buetow.org>2026-02-22 19:17:57 +0200
commitd629558394465b8956285edac324d67688ddd2c1 (patch)
treef511cf1ea87d916c11e85243361c366ae9843cd4
parent48280e828bc4737a91ed556226f7fdcb52679f87 (diff)
Rename binary from geheim to foostore
- go.mod: module path codeberg.org/snonux/geheim → codeberg.org/snonux/foostore - cmd/geheim/ → cmd/foostore/ - Magefile.go: binary/binaryName/mainPkg constants updated - internal/config: config file path ~/.config/geheim.json → ~/.config/foostore.json - All import paths and comments updated throughout - Delete geheim.rb (the original Ruby implementation, superseded by this Go rewrite) - CLAUDE.md rewritten to reflect the Go implementation, new binary name, build system (mage), and current package architecture Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--.serena/.gitignore1
-rw-r--r--.serena/project.yml117
-rw-r--r--CLAUDE.md50
-rw-r--r--Magefile.go6
-rw-r--r--cmd/foostore/main.go (renamed from cmd/geheim/main.go)8
-rwxr-xr-xgeheim.rb713
-rw-r--r--go.mod2
-rw-r--r--internal/cli/cli.go24
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/config/config_test.go8
-rw-r--r--internal/crypto/crypto_test.go2
-rw-r--r--internal/git/git_test.go2
-rw-r--r--internal/shell/shell_test.go2
-rw-r--r--internal/store/data.go4
-rw-r--r--internal/store/data_test.go2
-rw-r--r--internal/store/index.go4
-rw-r--r--internal/store/index_test.go2
-rw-r--r--internal/store/store.go6
-rw-r--r--internal/store/store_test.go6
19 files changed, 188 insertions, 773 deletions
diff --git a/.serena/.gitignore b/.serena/.gitignore
new file mode 100644
index 0000000..14d86ad
--- /dev/null
+++ b/.serena/.gitignore
@@ -0,0 +1 @@
+/cache
diff --git a/.serena/project.yml b/.serena/project.yml
new file mode 100644
index 0000000..38d1325
--- /dev/null
+++ b/.serena/project.yml
@@ -0,0 +1,117 @@
+# the name by which the project can be referenced within Serena
+project_name: "geheim"
+
+
+# list of languages for which language servers are started; choose from:
+# al bash clojure cpp csharp
+# csharp_omnisharp dart elixir elm erlang
+# fortran fsharp go groovy haskell
+# java julia kotlin lua markdown
+# matlab nix pascal perl php
+# php_phpactor powershell python python_jedi r
+# rego ruby ruby_solargraph rust scala
+# swift terraform toml typescript typescript_vts
+# vue yaml zig
+# (This list may be outdated. For the current list, see values of Language enum here:
+# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+# - For C, use cpp
+# - For JavaScript, use typescript
+# - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- go
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: "utf-8"
+
+# whether to use project's .gitignore files to ignore files
+ignore_all_files_in_gitignore: true
+
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
+ignored_paths: []
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
+read_only: false
+
+# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
+# Below is the complete list of tools for convenience.
+# To make sure you have the latest list of tools, and to view their descriptions,
+# execute `uv run scripts/print_tool_overview.py`.
+#
+# * `activate_project`: Activates a project by name.
+# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
+# * `create_text_file`: Creates/overwrites a file in the project directory.
+# * `delete_lines`: Deletes a range of lines within a file.
+# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
+# * `execute_shell_command`: Executes a shell command.
+# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
+# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
+# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
+# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
+# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
+# * `initial_instructions`: Gets the initial instructions for the current project.
+# Should only be used in settings where the system prompt cannot be set,
+# e.g. in clients you have no control over, like Claude Desktop.
+# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
+# * `insert_at_line`: Inserts content at a given line in a file.
+# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
+# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
+# * `list_memories`: Lists memories in Serena's project-specific memory store.
+# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
+# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
+# * `read_file`: Reads a file within the project directory.
+# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
+# * `remove_project`: Removes a project from the Serena configuration.
+# * `replace_lines`: Replaces a range of lines within a file with new content.
+# * `replace_symbol_body`: Replaces the full definition of a symbol.
+# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
+# * `search_for_pattern`: Performs a search for a pattern in the project.
+# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
+# * `switch_modes`: Activates modes by providing a list of their names
+# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
+# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
+# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
+# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+excluded_tools: []
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+fixed_tools: []
+
+# list of mode names to that are always to be included in the set of active modes
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this setting overrides the global configuration.
+# Set this to [] to disable base modes for this project.
+# Set this to a list of mode names to always include the respective modes for this project.
+base_modes:
+
+# list of mode names that are to be activated by default.
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# initial prompt for the project. It will always be given to the LLM upon activating the project
+# (contrary to the memories, which are loaded on demand).
+initial_prompt: ""
+
+# override of the corresponding setting in serena_config.yml, see the documentation there.
+# If null or missing, the value from the global config is used.
+symbol_info_budget:
diff --git a/CLAUDE.md b/CLAUDE.md
index 0a5d9b1..4e5a5c8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,19 +4,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
-`geheim.rb` is a single-file Ruby CLI for AES-256-CBC encryption of files and text. All secrets are stored in a Git repository with encrypted filenames (via SHA-256-hashed paths) and encrypted indices. The tool is designed for personal use on macOS, Linux, Android (Termux), and Windows.
+`foostore` is a Go CLI for AES-256-CBC encryption of files and text. All secrets are stored in a Git repository with encrypted filenames (via SHA-256-hashed paths) and encrypted indices. The tool is designed for personal use on macOS, Linux, Android (Termux), and Windows.
-## Running
+## Building and running
```bash
-ruby geheim.rb [command] [args]
+mage # build (produces ./bin/foostore)
+mage install # install to $GOPATH/bin (default ~/go/bin)
+mage test # run all tests
+mage vet # run go vet
```
-No build step. No gems beyond Ruby's standard library (openssl, readline, etc. are all stdlib). No Gemfile.
+Or run directly after building:
+
+```bash
+./bin/foostore [command] [args]
+```
## Testing
-There is no test suite. Manual testing is done by running the script directly.
+```bash
+go test ./...
+```
+
+Table-driven unit tests exist for all internal packages.
## Fish shell integration
@@ -26,7 +37,7 @@ There is no test suite. Manual testing is done by running the script directly.
## Configuration
-Config is read from `~/.config/geheim.json` at startup (merged over defaults in `Config::DEFAULTS`). Key fields:
+Config is read from `~/.config/foostore.json` at startup (merged over defaults). Key fields:
- `data_dir`: Git repo where encrypted `.index` / `.data` file pairs are stored (default: `~/git/geheimlager`)
- `key_file`: Path to the raw encryption key file (default: `~/.geheimlager.key`)
- `export_dir`: Temporary directory for decrypted exports (default: `~/.geheimlagerexport`)
@@ -37,29 +48,28 @@ The PIN (entered at startup or via `$PIN` env var) is used to derive the AES IV;
## Architecture
-All code lives in `geheim.rb`. The class/module hierarchy:
-
```
-Log (module) – formatted output: log/warn/prompt/fatal
-Git (module) – git add/rm/commit/status/sync operations
-Encryption (module) – AES-256-CBC encrypt/decrypt; reads PIN once into @@key/@@iv
-Clipboard (module) – paste password field to OS clipboard (macOS/GNOME)
-CommitFile – writes a file and git-adds it
- GeheimData – one encrypted secret; encrypt/decrypt/export/reimport
- Index – encrypted filename index; maps description → .data file via SHA256 hash
-Geheim – main logic: fzf picker, search/add/import/rm/shred/walk_indexes
-CLI – parses argv, runs the interactive shell loop (readline, vi mode)
+cmd/foostore/main.go – thin entry point: -version flag, signal context, calls cli.Run
+internal/version/ – Version constant
+internal/config/ – load ~/.config/foostore.json, merge over defaults
+internal/crypto/ – AES-256-CBC encrypt/decrypt (byte-identical to Ruby reference)
+internal/git/ – git add/rm/commit/status/sync subprocess wrappers
+internal/store/ – secret store: add/import/remove/search/export over .index+.data pairs
+internal/clipboard/ – paste password field to OS clipboard (macOS/GNOME)
+internal/shell/ – readline shell with vi mode, tab completion, history dedup
+internal/cli/ – command dispatch, interactive shell loop
```
Data storage: every entry is a pair of files in `data_dir`:
- `<sha256(dir)>/<sha256(name)>.index` – encrypted human-readable description/filename
- `<sha256(dir)>/<sha256(name)>.data` – encrypted file content
-Search (`walk_indexes`) decrypts every `.index` file and regex-matches against the description. `fzf` is used for interactive fuzzy selection.
+Search (`WalkIndexes`) decrypts every `.index` file and regex-matches against the description. `fzf` is used for interactive fuzzy selection.
## Key design constraints
-- Encryption key and IV are class-level (`@@key`, `@@iv`) — initialized once per process from the key file and PIN.
+- Encryption key and IV are initialised once per process from the key file and PIN (`internal/crypto.Cipher`).
- Commit messages are intentionally generic ("Changing stuff, not telling what in commit history") to avoid leaking metadata into git history.
-- Binary vs text detection in `Index#binary?` is extension-based; known text extensions (`.txt`, `.README`, `.conf`, `.csv`, `.md`) are whitelisted.
+- Binary vs text detection in `Index.IsBinary()` is extension-based; known text extensions (`.txt`, `.README`, `.conf`, `.csv`, `.md`) are whitelisted.
- The `shred` command (GNU coreutils) is used when available; falls back to `rm -Pfv`.
+- AES-256-CBC implementation is byte-identical to the original Ruby `geheim.rb` so existing encrypted databases remain readable.
diff --git a/Magefile.go b/Magefile.go
index 91631f0..571593b 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -16,9 +16,9 @@ import (
)
const (
- binary = "./bin/geheim"
- binaryName = "geheim"
- mainPkg = "./cmd/geheim"
+ binary = "./bin/foostore"
+ binaryName = "foostore"
+ mainPkg = "./cmd/foostore"
)
// Default builds the binary so that a bare `mage` invocation is equivalent to `mage build`.
diff --git a/cmd/geheim/main.go b/cmd/foostore/main.go
index 1d5f6e6..dd0da3d 100644
--- a/cmd/geheim/main.go
+++ b/cmd/foostore/main.go
@@ -1,4 +1,4 @@
-// main is the thin entry point for the geheim binary.
+// main is the thin entry point for the foostore binary.
// It handles the -version flag, sets up a signal-cancellable context,
// initialises the CLI, and exits with the code returned by Run.
// All command logic lives in internal/cli.
@@ -12,8 +12,8 @@ import (
"os/signal"
"syscall"
- "codeberg.org/snonux/geheim/internal/cli"
- "codeberg.org/snonux/geheim/internal/version"
+ "codeberg.org/snonux/foostore/internal/cli"
+ "codeberg.org/snonux/foostore/internal/version"
)
func main() {
@@ -37,7 +37,7 @@ func main() {
}
// flag.Args() returns arguments after flags, so flag-aware invocations
- // like `geheim -version` work while plain `geheim cat foo` still passes
+ // like `foostore -version` work while plain `foostore cat foo` still passes
// all args through unchanged.
os.Exit(c.Run(ctx, flag.Args()))
}
diff --git a/geheim.rb b/geheim.rb
deleted file mode 100755
index 8c1b7ed..0000000
--- a/geheim.rb
+++ /dev/null
@@ -1,713 +0,0 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-require 'base64'
-require 'digest'
-require 'digest/sha2'
-require 'fileutils'
-require 'io/console'
-require 'openssl'
-require 'json'
-require 'readline'
-
-VERSION = 'v0.3.1'
-
-# Configuration
-class Config
- CONFIG_PATH = File.join(Dir.home, '.config', 'geheim.json')
-
- DEFAULTS = {
- data_dir: File.join(Dir.home, 'git', 'geheimlager'),
- export_dir: File.join(Dir.home, '.geheimlagerexport'),
- key_file: File.join(Dir.home, '.geheimlager.key'),
- key_length: 32,
- enc_alg: 'AES-256-CBC',
- add_to_iv: 'Hello world',
- edit_cmd: 'hx',
- # edit_cmd: "nvim --cmd 'set noswapfile' --cmd 'set nobackup' --cmd 'set nowritebackup'",
- gnome_clipboard_cmd: 'gpaste-client',
- macos_clipboard_cmd: 'pbcopy',
- sync_repos: %w[git1 git2]
- }.freeze
-
- @@config = begin
- DEFAULTS.merge(JSON.parse(File.read(CONFIG_PATH), symbolize_names: true)).freeze
- rescue StandardError => e
- puts "Unable to read #{CONFIG_PATH}, using defaults! #{e}"
- DEFAULTS
- end
-
- def self.method_missing(method_name, *args, &block)
- return @@config[method_name] if @@config.key?(method_name)
-
- super
- end
-
- def self.respond_to_missing?(method_name, include_private = false)
- @@config.key?(method_name) || super
- end
-end
-
-# Logging capabilities
-module Log
- def log(message)
- out message, '>'
- end
-
- def warn(message)
- out message, 'WARN'
- end
-
- def prompt(message)
- out message, '<', :nonl
- end
-
- def fatal(message)
- out message, 'FATAL'
- exit 3
- end
-
- private
-
- def out(message, prefix, flag = :none)
- message = message.to_s unless message.is_a?(String)
- printer = flag == :nonl ? method(:print) : method(:puts)
- message.split("\n").each { |line| printer.call("#{prefix} #{line}") }
- end
-end
-
-# Git versioning
-module Git
- include Log
-
- def git_add(file:)
- Dir.chdir(File.dirname(file)) do
- log `git add "#{File.basename(file)}"`
- end
- end
-
- def git_rm(file:)
- Dir.chdir(File.dirname(file)) do
- log `git rm "#{File.basename(file)}"`
- end
- end
-
- def git_status
- Dir.chdir(Config.data_dir) do
- log `git status`
- end
- end
-
- def git_commit
- Dir.chdir(Config.data_dir) do
- log `git commit -a -m 'Changing stuff, not telling what in commit history'`
- end
- end
-
- def git_reset
- Dir.chdir(Config.data_dir) do
- log `git reset --hard`
- end
- end
-
- def git_sync
- log "Synchronising #{Config.data_dir}"
- Dir.chdir(Config.data_dir) do
- Config.sync_repos.each do |repo|
- log `git pull #{repo} master`
- log `git push #{repo} master`
- end
- log `git status`
- end
- end
-end
-
-# Encryption functionality
-module Encryption
- include Log
- @@key = nil
-
- def initialize
- super()
- return unless @@key.nil?
-
- pin = read_pin
- # Set up initialization vector
- iv = "#{pin * 2}#{Config.add_to_iv}#{pin * 2}"
- @@iv = iv.byteslice(0, 16)
- # ... and the encryption key!
- @@key = enforce_key_length(File.read(Config.key_file), Config.key_length)
- end
-
- def encrypt(plain:)
- aes = OpenSSL::Cipher.new(Config.enc_alg)
- aes.encrypt
- aes.key = @@key
- aes.iv = @@iv
-
- encrypted = aes.update(plain)
- encrypted << aes.final
-
- encrypted
- end
-
- def decrypt(encrypted:)
- aes = OpenSSL::Cipher.new(Config.enc_alg)
- aes.decrypt
- aes.key = @@key
- aes.iv = @@iv
-
- plain = aes.update(encrypted)
- plain << aes.final
- plain
- end
-
- private
-
- def enforce_key_length(key, force_size)
- new_key = key.dup
- new_key += key while new_key.size < force_size
- new_key[0, force_size]
- end
-
- def read_pin
- return ENV['PIN'] if ENV['PIN']
-
- prompt 'PIN: '
- return $stdin.gets.chomp if `uname`.include?('Android')
-
- $stdin.noecho(&:gets).chomp
- end
-end
-
-# Comitting a file
-class CommitFile
- include Git
- include Log
-
- def commit_content(file:, content:, force: false)
- if File.exist?(file) && !force
- warn "#{file} already exists. Use 'force' flag to overwrite."
- return false
- end
-
- dirname = File.dirname(file)
- FileUtils.mkdir_p(dirname) unless Dir.exist?(dirname)
-
- log "Writing #{file}"
- File.write(file, content)
- git_add(file: file)
- end
-end
-
-# Clipboard support
-module Clipboard
- include Log
- @clipboard_cmd = nil
-
- def initialize
- super()
- @clipboard_cmd =
- ENV['UNAME'] == 'Darwin' ? Config.macos_clipboard_cmd : Config.gnome_clipboard_cmd
- end
-
- def paste(data)
- fatal "Can't paste to clipboard" if @clipboard_cmd.nil?
- user, password, other = extract(data.to_s)
- read, write = IO.pipe
- pid = spawn(@clipboard_cmd, in: read)
- read.close
- write.write(password)
- write.close
- puts other
- Process.detach(pid)
- log "Pasted password for user '#{user}' to the clipboard"
- end
-
- private
-
- def extract(data)
- parts = data.match(/(?<User>\S+):(?<Password>\S+)/)
- cleared_data = data.gsub(/(\S+):\S+/, '\1:CENSORED')
- [parts['User'], parts['Password'], cleared_data]
- end
-end
-
-# Secret data store
-class GeheimData < CommitFile
- include Encryption
- include Git
- include Log
-
- attr_accessor :data, :exported_path
-
- def initialize(data_file:, data: nil)
- super()
-
- @exported_path = nil
- @data_path = "#{Config.data_dir}/#{data_file}"
- @data = data.nil? ? decrypt(encrypted: File.read(@data_path)) : data
- rescue StandardError => e
- fatal e
- end
-
- def to_s
- "\t#{@data.gsub("\n", "\n\t")}\n"
- end
-
- def rm
- log "Deleting #{@data_path}"
- git_rm(file: @data_path)
- end
-
- def export(destination_file:)
- destination_dir = "#{Config.export_dir}/#{File.dirname(destination_file)}"
- unless File.directory?(destination_dir)
- log "Creating #{destination_dir}"
- FileUtils.mkdir_p(destination_dir)
- end
-
- destination_path = "#{destination_dir}/#{File.basename(destination_file)}"
- log "Exporting to #{destination_path}"
- File.open(destination_path, 'w') { |fd| fd.write(@data) }
- @exported_path = destination_path
- end
-
- def reimport_after_export
- @data = File.read(@exported_path)
- commit(force: true)
- end
-
- def commit(force: false)
- commit_content(file: @data_path, content: encrypt(plain: @data), force: force)
- end
-end
-
-# Data store's encrypted index
-class Index < CommitFile
- attr_accessor :description, :data_file, :index_path
-
- include Encryption
- include Log
-
- def initialize(index_file:, description: nil)
- super()
- @data_file = index_file.sub('.index', '.data')
- @index_path = "#{Config.data_dir}/#{index_file}"
- @hash = File.basename(index_file).sub('.index', '')
- @description = description.nil? ? decrypt(encrypted: File.read(@index_path)) : description
- end
-
- def binary?
- if @description.include?('.txt')
- false
- elsif @description.include?('.README')
- false
- elsif @description.include?('.conf')
- false
- elsif @description.include?('.csv')
- false
- elsif @description.include?('.md')
- false
- else
- @description.include?('.')
- end
- end
-
- def get_data(data: nil)
- GeheimData.new(data_file: @data_file, data: data)
- end
-
- def to_s
- binary = binary? ? '(BINARY) ' : ''
- "#{@description}; #{binary}...#{@hash[-11...-1]}\n"
- end
-
- def <=>(other)
- @description <=> other.description
- end
-
- def rm
- log "Deleting #{@index_path}"
- git_rm(file: @index_path)
- end
-
- def commit(force: false)
- commit_content(file: @index_path, content: encrypt(plain: @description), force: force)
- end
-end
-
-# Secret store main class
-class Geheim
- include Clipboard
- include Log
-
- def initialize
- super()
- unless File.directory?(Config.data_dir)
- log "Creating #{Config.data_dir}"
- FileUtils.mkdir_p(Config.data_dir)
- end
- @regex_cache = {}
- end
-
- def fzf(flag = :none)
- # Need to read an index first before opening the pipe to initialize
- # the encryption PIN.
- fzf = nil
- walk_indexes do |index|
- fzf = IO.popen('fzf', 'r+') if fzf.nil?
- fzf.write(index)
- end
- fzf.close_write
- match = fzf.read.chomp
- log match unless flag == :silent
- match.split(';').first
- end
-
- def search(search_term: nil, action: :none)
- ec = 1
- search_term = fzf(:silent) if search_term.nil?
- indexes = []
- walk_indexes(search_term: search_term) do |index|
- indexes << index
- end
- indexes.sort.each do |index|
- print index
- ec = 0
- case action
- when :cat, :paste
- if index.binary?
- log 'Not displaying/pasting binary data!'
- ec = 2
- elsif action == :paste
- paste(index.get_data)
- else
- puts index.get_data
- end
- when :pathexport
- index.get_data.export(destination_file: index.description)
- when :export
- destination_file = File.basename(index.description)
- index.get_data.export(destination_file: destination_file)
- when :open
- destination_file = File.basename(index.description)
- index.get_data.export(destination_file: destination_file)
- shred_file(file: open_exported(file: destination_file), delay: 0)
- when :edit
- destination_file = File.basename(index.description)
- data = index.get_data
- data.export(destination_file: destination_file)
- external_edit(file: destination_file)
- data.reimport_after_export
- end
- index.description
- end
- ec
- end
-
- def add(description:)
- hash = hash_path(description)
-
- log 'Data: '
- data = $stdin.gets.chomp
- index = Index.new(index_file: "#{hash}.index", description: description)
- data = index.get_data(data: data)
-
- data.commit
- index.commit
- end
-
- def import(description: nil, action: nil, file: nil, dest_dir: nil, force: false)
- src_path = file.gsub('//', '/').gsub(%r{^\./}, '')
- dest_path = if dest_dir.nil?
- src_path
- elsif dest_dir.include?('.')
- dest_dir
- else
- "#{dest_dir}/#{File.basename(file)}".gsub('//', '/')
- end
-
- hash = hash_path(dest_path)
-
- fatal "#{file} does not exist!" unless File.exist?(src_path)
- log "Importing #{src_path} -> #{dest_path}"
- data = File.read(src_path)
- shred_file(file: src_path) if action == :newtxt
- description = dest_path if description.nil?
-
- index = Index.new(index_file: "#{hash}.index", description: description)
- data = index.get_data(data: data)
-
- data.commit(force: force)
- index.commit(force: force)
- end
-
- def import_recursive(directory:, dest_dir: nil)
- Dir.glob("#{directory}/**/*").each do |source_file|
- next if File.directory?(source_file)
-
- file = source_file.sub("#{directory}/", '')
- import(description: file, action: :import, file: source_file, dest_dir: dest_dir)
- end
- end
-
- def rm(search_term:)
- indexes = []
- walk_indexes(search_term: search_term) do |index|
- indexes << index
- end
- indexes.sort.each do |index|
- loop do
- log index
- prompt 'You really want to delete this? (y/n): '
- case $stdin.gets.chomp
- when 'y'
- data = index.get_data
- data.rm
- index.rm
- break
- when 'n'
- break
- end
- end
- end
- 0
- end
-
- def shred_all_exported
- log 'Shredding all exported files'
- ec = 0
- Dir.glob("#{Config.export_dir}/*").each do |file|
- next unless File.file?(file)
-
- if (ec_ = shred_file(file: file)).positive?
- ec = ec_
- end
- end
- ec
- end
-
- private
-
- def shred_file(file:, delay: 0)
- sleep(delay) if delay.positive?
- `which shred`
- if $?.success?
- run_command("shred -vu #{file}")
- else
- run_command("rm -Pfv #{file}")
- end
- end
-
- def open_exported(file:)
- file_path = "#{Config.export_dir}/#{file}"
-
- case ENV['UNAME']
- when 'Darwin'
- run_command("open #{file_path}")
- when 'Microsoft'
- run_command("winopen #{file_path}")
- when 'Linux'
- run_command("evince #{file_path}")
- else
- # Termux (Android)
- run_command("termux-open #{file_path}")
- end
- file_path
- end
-
- def external_edit(file:)
- file_path = "#{Config.export_dir}/#{file}"
- edit_cmd = "#{Config.edit_cmd} #{file_path}"
- log edit_cmd
- system(edit_cmd)
- file_path
- end
-
- def run_command(cmd)
- log "#{cmd}: #{`#{cmd}`}"
- $?.exitstatus
- end
-
- def walk_indexes(search_term: nil)
- @regex_cache[search_term] = Regexp.new(/#{search_term}/) unless @regex_cache.key?(search_term)
- regex = @regex_cache[search_term]
- Dir.glob("#{Config.data_dir}/**/*.index").each do |index_file|
- index = Index.new(index_file: index_file.sub(Config.data_dir, ''))
- yield index if search_term.nil? || index.description.force_encoding('UTF-8').match(regex)
- end
- end
-
- def hash_path(path_string)
- path = []
- path_string.gsub('//', '/').split('/').each do |part|
- path << Digest::SHA256.hexdigest(part)
- end
- path.join('/')
- end
-end
-
-# Command line interface
-class CLI
- include Git
- include Log
-
- COMMANDS = %w[
- ls search cat paste get add export pathexport open edit import
- import_r rm sync status commit reset fullcommit shred version
- commands help shell exit last
- ].freeze
-
- SEARCH_ACTIONS = {
- 'cat' => :cat,
- 'paste' => :paste,
- 'export' => :export,
- 'pathexport' => :pathexport,
- 'edit' => :edit,
- 'open' => :open
- }.freeze
-
- def initialize(interactive: false)
- super()
- @interactive = interactive
- setup_readline if interactive
- end
-
- def setup_readline
- # Enable vi editing mode
- Readline.vi_editing_mode
-
- # Set up tab completion
- Readline.completion_proc = proc do |input|
- # Get all available commands
- completions = COMMANDS.dup
-
- # If PIN is set, also include entry names for completion
- if ENV['PIN']
- begin
- geheim = Geheim.new
- geheim.walk_indexes do |index|
- completions << index.description.split(';').first.strip
- end
- rescue StandardError
- # Ignore errors during completion
- end
- end
-
- completions.grep(/^#{Regexp.escape(input)}/)
- end
-
- # Set up completion append character
- Readline.completion_append_character = ' '
- end
-
- def commands
- puts COMMANDS
- 0
- end
-
- def help
- log <<~HELP
- ls
- SEARCHTERM
- search SEARCHTERM
- cat SEARCHTERM
- get SEARCHTERM
- add DESCRIPTION
- export|pathexport|open|edit FILE
- import FILE [DEST_DIRECTORY] [force]
- import_r DIRECTORY [DEST_DIRECTORY]
- rm SEARCHTERM
- sync|status|commit|reset|fullcommit
- shred
- version
- commands
- help
- shell
- HELP
- 0
- end
-
- def shell_loop(argv)
- last_result = nil
- ec = 0
-
- loop do
- if argv.empty? || @interactive
- @interactive ||= true
- setup_readline unless Readline.completion_proc
-
- input = Readline.readline('% ', true)
- break if input.nil? # Handle Ctrl+D
-
- argv = input.strip.split
-
- # Don't add empty lines or duplicates to history
- Readline::HISTORY.pop if argv.empty? ||
- (Readline::HISTORY.length > 1 &&
- Readline::HISTORY[-1] == Readline::HISTORY[-2])
- end
-
- geheim = Geheim.new
- action = argv.first
- search_term = argv.length < 2 ? last_result : argv[1]
-
- ec = case action
- when 'ls'
- geheim.search(search_term: '.')
- when 'search'
- geheim.search(search_term: search_term)
- when *SEARCH_ACTIONS.keys
- geheim.search(search_term: search_term, action: SEARCH_ACTIONS[action])
- when 'add'
- geheim.add(description: search_term)
- when 'import'
- geheim.import(file: search_term, dest_dir: argv[2], force: !argv[3].nil?)
- when 'import_r'
- geheim.import_recursive(directory: search_term, dest_dir: argv[2])
- when 'rm'
- geheim.rm(search_term: search_term)
- when 'help'
- help
- when 'shell'
- @interactive = true
- log 'Switching to interactive mode'
- when 'exit'
- @interactive = false
- log 'Good bye'
- when 'status'
- git_status
- when 'commit'
- git_commit
- when 'reset'
- git_reset
- when 'sync'
- git_sync
- when 'fullcommit'
- git_sync
- git_commit
- git_sync
- when 'shred'
- geheim.shred_all_exported
- when 'version'
- log "geheim #{VERSION}"
- 0
- when 'commands'
- commands
- when 'last'
- puts last_result
- last_result
- when nil
- last_result = geheim.fzf
- else
- last_result = geheim.search(search_term: action)
- end
- break unless @interactive
- end
-
- ec
- end
-end
-
-exit(CLI.new.shell_loop(ARGV))
diff --git a/go.mod b/go.mod
index c979e3e..623e7ac 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module codeberg.org/snonux/geheim
+module codeberg.org/snonux/foostore
go 1.24.0
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 0ae4dde..fb76e90 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -1,7 +1,7 @@
// Package cli implements the command-line interface for geheim.
// It mirrors the Ruby CLI class (geheim.rb lines 551-713): parsing argv,
// dispatching commands, and running an optional interactive readline shell.
-// Run() is the top-level entry point called by cmd/geheim/main.go.
+// Run() is the top-level entry point called by cmd/foostore/main.go.
package cli
import (
@@ -15,13 +15,13 @@ import (
"runtime"
"strings"
- "codeberg.org/snonux/geheim/internal/clipboard"
- "codeberg.org/snonux/geheim/internal/config"
- "codeberg.org/snonux/geheim/internal/crypto"
- "codeberg.org/snonux/geheim/internal/git"
- "codeberg.org/snonux/geheim/internal/shell"
- "codeberg.org/snonux/geheim/internal/store"
- "codeberg.org/snonux/geheim/internal/version"
+ "codeberg.org/snonux/foostore/internal/clipboard"
+ "codeberg.org/snonux/foostore/internal/config"
+ "codeberg.org/snonux/foostore/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/git"
+ "codeberg.org/snonux/foostore/internal/shell"
+ "codeberg.org/snonux/foostore/internal/store"
+ "codeberg.org/snonux/foostore/internal/version"
)
// CommandList is the canonical list of supported commands, ordered to match
@@ -60,7 +60,7 @@ type CLI struct {
}
// New initialises all runtime dependencies (config, PIN, cipher, store, git,
-// clipboard, shell) and returns a ready-to-use CLI. cmd/geheim/main.go calls
+// clipboard, shell) and returns a ready-to-use CLI. cmd/foostore/main.go calls
// New with a signal-cancellable context so that long-running operations (fzf,
// external editors) are interrupted cleanly on SIGINT/SIGTERM.
func New(ctx context.Context) (*CLI, error) {
@@ -70,7 +70,7 @@ func New(ctx context.Context) (*CLI, error) {
// Run dispatches argv (typically os.Args[1:]) to the appropriate handler or
// enters the interactive shell loop. Returns an exit code suitable for
// os.Exit. The caller is responsible for calling sh.Close() when done;
-// cmd/geheim/main.go does this via defer.
+// cmd/foostore/main.go does this via defer.
func (c *CLI) Run(ctx context.Context, argv []string) int {
defer c.sh.Close()
return c.run(ctx, argv)
@@ -297,7 +297,7 @@ func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (in
return 0, "", true
case "version":
- logMsg(fmt.Sprintf("geheim %s", version.Version))
+ logMsg(fmt.Sprintf("foostore %s", version.Version))
return 0, "", true
case "commands":
@@ -315,7 +315,7 @@ func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (in
// dispatch is called, so this branch only fires in one-shot mode where
// switching to interactive mode is not meaningful. We print a notice and
// exit cleanly rather than silently doing nothing.
- logMsg("Use geheim without arguments to enter interactive mode")
+ logMsg("Use foostore without arguments to enter interactive mode")
return 0, "", true
case "exit":
diff --git a/internal/config/config.go b/internal/config/config.go
index f48a046..6354d83 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -14,7 +14,7 @@ import (
)
// configPath is the location of the optional user config file.
-const configPath = "~/.config/geheim.json"
+const configPath = "~/.config/foostore.json"
// Config holds all application-wide configuration values.
// JSON field names use snake_case to match geheim.rb Config::DEFAULTS keys.
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index ae2d6c1..3f8ace9 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -9,7 +9,7 @@ import (
"testing"
)
-// writeUserConfig creates the ~/.config/geheim.json file inside the given
+// writeUserConfig creates the ~/.config/foostore.json file inside the given
// HOME directory (which must already exist). Used to exercise Load() directly.
func writeUserConfig(t *testing.T, home, content string) {
t.Helper()
@@ -17,7 +17,7 @@ func writeUserConfig(t *testing.T, home, content string) {
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
- path := filepath.Join(cfgDir, "geheim.json")
+ path := filepath.Join(cfgDir, "foostore.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -167,7 +167,7 @@ func TestLoad_invalid_json(t *testing.T) {
// TestLoad_missing_file_no_warning verifies that a missing config file does NOT
// produce any output — absence is normal for a first-run or unconfigured install.
func TestLoad_missing_file_no_warning(t *testing.T) {
- dir := t.TempDir() // no geheim.json inside
+ dir := t.TempDir() // no foostore.json inside
t.Setenv("HOME", dir)
stderr := captureStderr(func() { _ = Load() })
@@ -188,7 +188,7 @@ func TestLoad_unreadable_file(t *testing.T) {
writeUserConfig(t, dir, `{"edit_cmd":"nvim"}`)
// Make the file unreadable.
- cfgPath := filepath.Join(dir, ".config", "geheim.json")
+ cfgPath := filepath.Join(dir, ".config", "foostore.json")
if err := os.Chmod(cfgPath, 0o000); err != nil {
t.Fatalf("Chmod: %v", err)
}
diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go
index 556a12e..fb37d69 100644
--- a/internal/crypto/crypto_test.go
+++ b/internal/crypto/crypto_test.go
@@ -294,7 +294,7 @@ func TestEncryptGolden(t *testing.T) {
wantHex: "6190f985f42374d24dd8e17b3b2d6057",
},
{
- name: "Hello world / pin=abcd1234 / 64x 'y'",
+ name: "Hello world / pin=abcd1234 / 64x 'y'",
plaintext: []byte("Hello, world!"),
pin: "abcd1234",
// 64 bytes of 'y': key is already 2x the required 32 bytes so it gets truncated.
diff --git a/internal/git/git_test.go b/internal/git/git_test.go
index 87d2ecf..fa1f8f7 100644
--- a/internal/git/git_test.go
+++ b/internal/git/git_test.go
@@ -8,7 +8,7 @@ import (
"strings"
"testing"
- "codeberg.org/snonux/geheim/internal/git"
+ "codeberg.org/snonux/foostore/internal/git"
)
// initRepo creates a temporary git repository with a minimal config so that
diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go
index 19491e3..71eafa8 100644
--- a/internal/shell/shell_test.go
+++ b/internal/shell/shell_test.go
@@ -8,7 +8,7 @@ import (
"os"
"testing"
- "codeberg.org/snonux/geheim/internal/shell"
+ "codeberg.org/snonux/foostore/internal/shell"
)
// isTTY returns true when stdin is connected to an actual terminal.
diff --git a/internal/store/data.go b/internal/store/data.go
index b9423f2..fb33bf0 100644
--- a/internal/store/data.go
+++ b/internal/store/data.go
@@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
- "codeberg.org/snonux/geheim/internal/crypto"
- "codeberg.org/snonux/geheim/internal/git"
+ "codeberg.org/snonux/foostore/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/git"
)
// Data holds a decrypted secret blob and the paths used to persist it.
diff --git a/internal/store/data_test.go b/internal/store/data_test.go
index 88b80cd..42721af 100644
--- a/internal/store/data_test.go
+++ b/internal/store/data_test.go
@@ -8,7 +8,7 @@ import (
"path/filepath"
"testing"
- "codeberg.org/snonux/geheim/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/crypto"
)
// --- helpers -----------------------------------------------------------------
diff --git a/internal/store/index.go b/internal/store/index.go
index 9064971..923082f 100644
--- a/internal/store/index.go
+++ b/internal/store/index.go
@@ -11,8 +11,8 @@ import (
"path/filepath"
"strings"
- "codeberg.org/snonux/geheim/internal/crypto"
- "codeberg.org/snonux/geheim/internal/git"
+ "codeberg.org/snonux/foostore/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/git"
)
// Index represents a decrypted .index file and its associated .data path.
diff --git a/internal/store/index_test.go b/internal/store/index_test.go
index a16d757..57aea8c 100644
--- a/internal/store/index_test.go
+++ b/internal/store/index_test.go
@@ -9,7 +9,7 @@ import (
"strings"
"testing"
- "codeberg.org/snonux/geheim/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/crypto"
)
// newTestIndexCipher is a local helper to avoid import cycle via store_test.go.
diff --git a/internal/store/store.go b/internal/store/store.go
index fe9f132..0dfa5e7 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -19,9 +19,9 @@ import (
"sort"
"strings"
- "codeberg.org/snonux/geheim/internal/config"
- "codeberg.org/snonux/geheim/internal/crypto"
- "codeberg.org/snonux/geheim/internal/git"
+ "codeberg.org/snonux/foostore/internal/config"
+ "codeberg.org/snonux/foostore/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/git"
)
// Action describes what to do with each matching secret during a Search call.
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 950c010..9670a01 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -13,9 +13,9 @@ import (
"strings"
"testing"
- "codeberg.org/snonux/geheim/internal/config"
- "codeberg.org/snonux/geheim/internal/crypto"
- "codeberg.org/snonux/geheim/internal/git"
+ "codeberg.org/snonux/foostore/internal/config"
+ "codeberg.org/snonux/foostore/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/git"
)
// --- test helpers ------------------------------------------------------------