# Copyright 2026 Relay, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExCredstash.CLI do
@moduledoc """
Command-line interface for ExCredstash.
Build with: `mix escript.build`
Usage: `./credstash <command> [options]`
## Available Commands
- `setup` - Create DynamoDB table
- `put <name> [value]` - Store a secret (reads stdin if no value)
- `get <name>` - Retrieve a secret
- `getall` - Retrieve all secrets
- `list` - List all credentials
- `keys` - List unique credential names
- `delete <name>` - Delete all versions of a secret
## Global Options
- `-r, --region` - AWS region
- `-t, --table` - DynamoDB table name
- `-k, --key` - KMS key ID
- `-h, --help` - Show help
"""
@doc """
Main entry point for the escript.
"""
@spec main([String.t()]) :: :ok | no_return()
def main(argv) do
{opts, args, invalid} =
OptionParser.parse(argv,
strict: [
region: :string,
table: :string,
version: :string,
key: :string,
autoversion: :boolean,
digest: :string,
comment: :string,
noline: :boolean,
format: :string,
help: :boolean
],
aliases: [
r: :region,
t: :table,
v: :version,
k: :key,
a: :autoversion,
d: :digest,
c: :comment,
n: :noline,
f: :format,
h: :help
]
)
# Handle invalid options
if invalid != [] do
Enum.each(invalid, fn {opt, _} ->
IO.puts(:stderr, "Unknown option: #{opt}")
end)
print_help()
System.halt(1)
end
# Handle --help flag
if opts[:help] do
print_help()
System.halt(0)
end
run_command(args, opts)
end
# Command dispatch
defp run_command(["setup" | _rest], opts) do
result = ExCredstash.setup(build_opts(opts))
case result do
{:ok, :created} ->
IO.puts("Credential store table created.")
IO.puts("Go read the README about how to create your KMS key.")
{:ok, :exists} ->
IO.puts("Credential store table already exists.")
{:error, reason} ->
handle_error(reason)
end
end
defp run_command(["put", name, value | context_args], opts) do
context = parse_context(context_args)
put_secret(name, value, context, opts)
end
defp run_command(["put", name | context_args], opts) do
# Read value from stdin when no value provided
context = parse_context(context_args)
value = read_stdin()
put_secret(name, value, context, opts)
end
defp run_command(["get", name | context_args], opts) do
context = parse_context(context_args)
get_opts =
opts
|> build_opts()
|> maybe_add_context(context)
|> maybe_add_version(opts[:version])
case ExCredstash.get(name, get_opts) do
{:ok, secret} ->
output = format_output(secret, opts[:format])
if opts[:noline] do
IO.write(output)
else
IO.puts(output)
end
{:error, :not_found} ->
IO.puts(:stderr, "Error: Secret '#{name}' not found")
System.halt(1)
{:error, reason} ->
handle_error(reason)
end
end
defp run_command(["getall" | context_args], opts) do
context = parse_context(context_args)
get_opts =
opts
|> build_opts()
|> maybe_add_context(context)
case ExCredstash.get_all(get_opts) do
{:ok, secrets} ->
output = format_output(secrets, opts[:format] || "json")
IO.puts(output)
{:error, reason} ->
handle_error(reason)
end
end
defp run_command(["list" | _rest], opts) do
case ExCredstash.list(build_opts(opts)) do
{:ok, credentials} ->
if Enum.empty?(credentials) do
IO.puts("No credentials found.")
else
max_name_len =
credentials
|> Enum.map(fn item -> String.length(item.name) end)
|> Enum.max(fn -> 0 end)
credentials
|> Enum.sort_by(fn item -> {item.name, item.version} end)
|> Enum.each(fn item ->
comment = item[:comment] || ""
IO.puts(
"#{String.pad_trailing(item.name, max_name_len)} -- version #{item.version} -- comment #{comment}"
)
end)
end
{:error, reason} ->
handle_error(reason)
end
end
defp run_command(["keys" | _rest], opts) do
case ExCredstash.keys(build_opts(opts)) do
{:ok, names} ->
Enum.each(names, &IO.puts/1)
{:error, reason} ->
handle_error(reason)
end
end
defp run_command(["delete", name | _rest], opts) do
case ExCredstash.delete(name, build_opts(opts)) do
{:ok, count} ->
IO.puts("Deleted #{count} version(s) of '#{name}'")
{:error, reason} ->
handle_error(reason)
end
end
defp run_command(["help" | _rest], _opts), do: print_help()
defp run_command([], _opts), do: print_help()
defp run_command([unknown | _rest], _opts) do
IO.puts(:stderr, "Unknown command: #{unknown}")
print_help()
System.halt(1)
end
# Helper functions
@doc false
def parse_context(args) do
args
|> Enum.filter(&String.contains?(&1, "="))
|> Enum.map(fn str ->
[key | rest] = String.split(str, "=", parts: 2)
{key, Enum.join(rest, "=")}
end)
|> Map.new()
end
defp put_secret(name, value, context, opts) do
# Handle special value formats
actual_value = read_value(value)
put_opts =
opts
|> build_opts()
|> maybe_add_context(context)
|> maybe_add_key_id(opts[:key])
|> maybe_add_digest(opts[:digest])
|> maybe_add_comment(opts[:comment])
|> maybe_add_version_for_put(opts)
case ExCredstash.put(name, actual_value, put_opts) do
{:ok, version} ->
IO.puts("#{name} has been stored (version #{version})")
{:error, {:already_exists, version}} ->
IO.puts(
:stderr,
"#{name} version #{version} is already in the credential store. " <>
"Use the -v flag to specify a new version"
)
System.halt(1)
{:error, :already_exists} ->
# Fallback for legacy error format
IO.puts(
:stderr,
"#{name} is already in the credential store. " <>
"Use the -v flag to specify a new version"
)
System.halt(1)
{:error, reason} ->
handle_error(reason)
end
end
defp read_value("-"), do: read_stdin()
defp read_value("@" <> filename) do
case File.read(Path.expand(filename)) do
{:ok, content} ->
content
{:error, reason} ->
IO.puts(:stderr, "Error reading file #{filename}: #{inspect(reason)}")
System.halt(1)
end
end
defp read_value(value), do: value
defp read_stdin do
IO.read(:stdio, :eof)
|> String.trim_trailing("\n")
end
defp build_opts(opts) do
[]
|> maybe_add_region(opts[:region])
|> maybe_add_table(opts[:table])
end
defp maybe_add_region(opts, nil), do: opts
defp maybe_add_region(opts, region), do: Keyword.put(opts, :region, region)
defp maybe_add_table(opts, nil), do: opts
defp maybe_add_table(opts, table), do: Keyword.put(opts, :table, table)
defp maybe_add_context(opts, context) when map_size(context) == 0, do: opts
defp maybe_add_context(opts, context), do: Keyword.put(opts, :context, context)
defp maybe_add_key_id(opts, nil), do: opts
defp maybe_add_key_id(opts, key_id), do: Keyword.put(opts, :key_id, key_id)
defp maybe_add_digest(opts, nil), do: opts
defp maybe_add_digest(opts, digest) do
# Convert string digest to atom (e.g., "SHA256" -> :sha256)
digest_atom =
digest
|> String.downcase()
|> String.to_atom()
Keyword.put(opts, :digest, digest_atom)
end
defp maybe_add_comment(opts, nil), do: opts
defp maybe_add_comment(opts, comment), do: Keyword.put(opts, :comment, comment)
defp maybe_add_version(opts, nil), do: opts
defp maybe_add_version(opts, version) do
# Try to parse as integer, otherwise keep as string
parsed =
case Integer.parse(version) do
{int, ""} -> int
_ -> version
end
Keyword.put(opts, :version, parsed)
end
defp maybe_add_version_for_put(opts, cli_opts) do
cond do
cli_opts[:autoversion] ->
# Auto-increment: don't specify version, let ExCredstash auto-increment
opts
cli_opts[:version] ->
# Parse the version string to integer
case Integer.parse(cli_opts[:version]) do
{int, ""} ->
Keyword.put(opts, :version, int)
_ ->
IO.puts(:stderr, "Error: Version must be an integer")
System.halt(1)
end
true ->
# Default: use version 1 (matches Python credstash behavior)
# This will error if version 1 already exists
Keyword.put(opts, :version, 1)
end
end
@doc false
def format_output(data, "json") when is_map(data) do
Jason.encode!(data, pretty: true)
end
def format_output(data, "csv") when is_map(data) do
Enum.map_join(data, "\n", fn {k, v} -> "#{k},#{v}" end)
end
def format_output(data, "dotenv") when is_map(data) do
Enum.map_join(data, "\n", fn {k, v} ->
key = String.upcase(k)
"#{key}='#{v}'"
end)
end
def format_output(data, _format), do: to_string(data)
defp handle_error(reason) do
IO.puts(:stderr, "Error: #{inspect(reason)}")
System.halt(1)
end
@doc false
def print_help do
IO.puts("""
ExCredstash - Credential Management with AWS KMS & DynamoDB
Usage: credstash <command> [options] [arguments]
Commands:
setup Create the DynamoDB table
put <name> [value] Store a secret (reads stdin if no value)
get <name> Retrieve a secret
getall Retrieve all secrets
list List all credentials with versions
keys List unique credential names
delete <name> Delete all versions of a secret
help Show this help
Global Options:
-r, --region <region> AWS region
-t, --table <table> DynamoDB table name (default: credential-store)
-h, --help Show this help
Put Options:
-k, --key <key_id> KMS key ID (default: alias/credstash)
-v, --version <version> Specify version (default: 1)
-a, --autoversion Auto-increment to next version
-d, --digest <algo> Hash algorithm (SHA256, SHA384, SHA512)
-c, --comment <text> Comment for the secret
Get Options:
-v, --version <version> Get specific version (default: latest)
-n, --noline Don't print trailing newline
-f, --format <format> Output format: json, csv, dotenv (default: plain)
Context:
Additional arguments as key=value pairs are passed as KMS encryption context.
Examples:
credstash setup -r us-east-1
credstash put db_password secret123 -r us-east-1
credstash put api_key -k alias/mykey -c "API key for service"
echo "secret" | credstash put my_secret
credstash put file_secret @/path/to/file
credstash get db_password -r us-east-1
credstash get api_key environment=production
credstash getall -f json
credstash list
credstash keys
credstash delete old_secret
""")
end
end