@jalasem/dss
v2.1.1
Published
Dev Spaces Switcher (DSS) - Seamlessly manage isolated development environments with separate SSH keys and Git configurations. Enhanced UI with beautiful tables and improved documentation.
Maintainers
Readme
Dev Spaces Switcher (DSS)
Manage isolated development identities — separate SSH keys and Git configuration per client, employer, or project.
Stop hand-editing ~/.gitconfig and ~/.ssh/config every time you switch between projects, clients, or employers.
DSS keeps a set of named identities — a name, email, Git host, and (usually) an SSH key — and applies the right one automatically, either globally or pinned to a single repository.
Quick Start
Install DSS globally with your preferred package manager:
npm
npm install -g @jalasem/dssyarn
yarn global add @jalasem/dsspnpm
pnpm add -g @jalasem/dssThen just run it:
dssWith no identities configured yet, DSS greets you and offers to create your first one right there — no separate tutorial command. Once you have identities, bare dss becomes a fast dashboard: which identity applies in the current directory (repo-bound or global default), and a local health summary (key present, ssh-agent status).
Identities
An identity is:
- a name (slugified for use as an SSH key directory and CLI argument),
- an email and username (your Git
user.email/user.name), - a Git host —
github.com,gitlab.com,bitbucket.org, or any custom host, - an optional SSH key, Ed25519 by default, generated with the system
ssh-keygenbinary (no bundled key-generation dependency). You can set a passphrase; on macOS the key is added to the ssh-agent with--apple-use-keychainso the passphrase survives a reboot.
Identities are stored under ~/.dss/spaces/config.json (schema version: 2, versioned and pretty-printed, written atomically). A pre-v2 config is migrated silently the first time it's read.
Core Commands
| Command | Description |
|---|---|
| dss | Dashboard: which identity applies here, and a quick health check |
| dss ls | List all identities |
| dss use [identityName] | Switch the global active identity (interactive picker if omitted); --dry-run previews the change |
| dss new | Create a new identity (interactive: name, email, username, host, optional key generation) |
| dss edit [identityName] | Modify an existing identity's name, email, username, or host |
| dss rm [identityName] | Remove an identity; --dry-run previews it. Cannot remove the active identity. Key files are not deleted from disk |
| dss link [identityName] | Bind an identity to the current repository (or -p <path> / -r [parent] for recursive binding); --dry-run previews |
| dss unlink | Remove the repo-local binding (-p <path> for an explicit repo) |
| dss clone <url> [directory] | Clone a repository with the right identity (rule/host-aware selection), binding it automatically; -i/--identity <name> overrides selection |
| dss rule add\|ls\|rm <directory> [identityName] | Add/list/remove a directory rule: an identity that applies automatically under a directory tree, compiled to a native git includeIf |
| dss guard install\|uninstall\|check | Wrong-identity guard: install/remove an opt-in pre-commit hook, or run the check it performs directly |
| dss key show\|copy\|rotate [identityName] | Print, clipboard-copy, or regenerate an identity's SSH key |
| dss config export\|import [path] | Back up or restore identity configuration (SSH keys are never included) |
| dss doctor [identityName] | Full health check: key files/permissions, ssh-agent, ssh-config, host auth, Git identity drift, repo binding, commit-history drift |
| dss status | Show the current repository's DSS binding |
| dss prompt | Fast shell-prompt identity segment for scripts/starship/oh-my-zsh (see Shell prompt integration); --source adds a source hint |
| dss completion [shell] | Generate a bash/zsh/fish completion script |
Run dss <command> --help for the exact options each command accepts.
Deprecated aliases (removed in v3)
The pre-v2 command names still work, but each prints a deprecation warning to stderr and delegates to its replacement above:
| Old name | New name |
|---|---|
| list | ls |
| switch | use |
| add | new |
| remove | rm |
| bind | link |
| unbind | unlink |
| export | config export |
| import | config import |
| test | doctor |
| inspect | doctor |
batch, bulk, and onboard are gone entirely, with no alias — replaced by the automatic first-run flow (above) and per-identity dss key rotate.
How switching works
dss use doesn't write directly into your own ~/.gitconfig. It writes user.name/user.email (and, when the identity has a key, core.sshCommand) into a single DSS-managed file, ~/.dss/active.gitconfig, and makes sure your global Git config includes it — an includeIf-first model. Switching identities means rewriting one small file, not splicing lines into a file DSS doesn't own. The same key-selection logic also updates the matching Host block in ~/.ssh/config (parsed and rewritten in place, never spliced) and loads the key into the ssh-agent.
Repository-local binding
Binding is for pinning one identity to a specific repository — the identity your editor's Git integration picks up automatically — independent of your global active identity:
# From inside a repository
dss link personal
# An explicit path
dss link personal --path ~/Desktop/side-project
# Every Git repository under a parent folder
dss link work --recursive ~/code/acme
dss link work -r ~/code/acme --dry-run # preview first
# Inspect or remove the current repository's binding
dss status
dss unlinkEach binding is a private config file under .git/, referenced from the repository's Git config via an includeIf.gitdir: condition — it's never committed. Linking the main checkout also applies to every linked worktree of that repository, including ones created later (e.g. by git worktree add or an AI coding agent); dss status shows those as "inherited from the main checkout". Running dss link inside a linked worktree binds just that worktree, overriding the inherited identity. dss unlink removes only that checkout's own conditions and file, leaving extensions.worktreeConfig and any unrelated includes untouched. Bindings made before 2.1.1 cover only the main checkout until you run dss link there again. Binding requires Git 2.30 or newer.
Directory rules
Binding pins one identity to one already-existing repository. A directory rule is broader: it applies an identity to every Git repository under a directory tree — existing ones and any you clone or create there later — without touching each repository's own config:
dss rule add ~/code/acme work # any repo under ~/code/acme uses "work"
dss rule ls
dss rule rm ~/code/acmeEach rule compiles to a native git includeIf "gitdir:<directory>/" section in ~/.dss/rules.gitconfig, pointing at that identity's own ~/.dss/identities/<slug>.gitconfig — the same [user]/[core] sshCommand shape as active.gitconfig. ~/.dss/rules.gitconfig is included from your global Git config once, right after active.gitconfig's own include, so a rule overrides the global default inside its directory without needing per-repository setup. This is entirely git's own conditional-include mechanism — nothing DSS-specific has to run for it to take effect, even in an editor's or CI's own git invocation.
Precedence (most to least specific): a repo-local binding (dss link) wins over a matching directory rule, which wins over the global active identity (dss use) — matching the order git itself resolves conditional includes and repo-local config.
Directories must exist to add a rule (~ is expanded, symlinks resolved); the identity doesn't need a key. Removing every rule leaves an empty (harmless) rules.gitconfig — its include.path entry stays in place. dss doctor flags rule drift (the effective git identity in the current directory not matching a configured rule) the same way it flags a stale binding or global switch.
Wrong-identity guard
dss doctor already flags recent commits authored under the wrong identity (a "Commit
history" section, warning-class — attention, not a hard failure) whenever it runs inside a
repository with commits. dss guard is the opt-in, proactive half: a pre-commit hook that
refuses a commit outright when it would be made under the wrong identity.
dss guard install # install the hook in the current repository
dss guard check # run the same check directly (what the hook runs)
dss guard uninstall # remove itdss guard install writes a small #!/bin/sh script to the repository's .git/hooks/pre-commit
(resolved via git rev-parse --git-path, so it works correctly from a linked worktree too) —
never overwriting a pre-commit hook DSS didn't write; a foreign hook is refused with the exact
line to add to it by hand. Re-running install over a DSS-installed hook rewrites it silently.
dss guard check resolves the identity expected here the same way the dashboard does (bound >
directory rule > global default) and compares it against the EFFECTIVE git config user.email
in the repository; a mismatch exits 1 with a message naming the fix (dss use/dss link). If
dss isn't on PATH when a commit happens, the hook no-ops instead of blocking every commit —
and the same fail-open guarantee holds for a REACHABLE dss too: a corrupt or
newer-than-this-build config, or any other error short of a genuinely resolved identity
disagreeing with the effective one, allows the commit (exit 0, a one-line note on stderr)
rather than blocking it. Only a real, fully-resolved mismatch ever exits 1.
Cloning with the right identity
dss clone gets you the right identity from the FIRST clone — no git clone followed by a separate dss link step:
dss clone [email protected]:acme/api.git # picks an identity, clones, binds
dss clone [email protected]:acme/api.git ~/code/api # explicit destination
dss clone https://github.com/acme/api.git -i work # override selection with -i/--identityIt understands scp-like ssh (git@host:org/repo.git), ssh://host[:port]/..., https:///http://, git://, and a local filesystem path (for cloning a fixture, a bundle, or another local repository). The destination defaults to ./<repoName> (the URL's basename, minus .git) under the current directory, and must not already exist.
Identity selection (first hit wins — DSS never guesses on ambiguity):
-i/--identity <name>— explicit override.- A directory rule matching where the clone will live (see Directory rules above) — cloning into a ruled directory tree just works, same as an existing repository there would.
- A host match: if exactly one identity's host matches the URL's host, it's picked automatically; several prompt interactively (or exit
2non-interactively); a local-path URL has no host, so this step is always skipped. - Otherwise, an interactive pick among every identity; non-interactively, exit
2naming-i/--identity.
A keyed identity cloning over ssh (scp-like or ssh://) runs with GIT_SSH_COMMAND set to that identity's key, same as a repo-local binding's core.sshCommand; a keyless identity on an ssh URL warns and falls back to your ambient SSH setup. https/git:///local clones never need a key. Git's own clone progress streams to your terminal as it happens; on failure, dss clone exits 1 with git's own error.
Once the clone succeeds, dss clone binds it to the chosen identity the same way dss link would (same underlying call, same binding registry) — a bind failure after a successful clone is a warning, not a failure: the clone stands, and --json's bound comes back false.
Shell prompt integration
dss prompt emits a single-line identity segment for the CURRENT directory and exits — the one command tuned for "runs on every shell prompt render." It resolves the identity the exact same way the dashboard does (bound > directory rule > global default), and it's deliberately not cached: Node's own process-startup cost dominates the latency, not the (already cheap) resolution work, so a cache file would add complexity without making it faster. It also never errors — see Exit codes — and never prompts, so it's safe to wire into PROMPT/PS1/command_timeout-bounded contexts unconditionally.
dss prompt # "● work" (colored) in a terminal; bare "work" once piped or NO_COLOR is set
dss prompt --source # "● work" plus a dim " (repo)" / " (rule)" / " (global)" hint — off by default
dss prompt --json # { identity: string | null, source: "bound" | "rule" | "global" | null }Output is empty (nothing at all, exit 0) whenever no identity applies here — a prompt segment should vanish, never print "none" or an error.
starship
Add a [custom.dss] block to ~/.config/starship.toml:
[custom.dss]
command = "dss prompt"
when = "true"
format = "[$output]($style) "
style = "cyan"
# Node's fixed process-startup cost (not dss's own resolution, which is
# just a store read plus at most one `git rev-parse`) can exceed
# starship's default 500ms command_timeout on a slow machine or a cold
# filesystem cache — bump it if the segment intermittently disappears:
command_timeout = 1000Prefer the segment to show up only inside Git repositories? Use a git-aware when instead of "true":
when = "test -d .git || git rev-parse --git-dir > /dev/null 2>&1"oh-my-zsh / plain zsh
dss_prompt_segment() {
local segment
segment="$(dss prompt)"
[[ -n "$segment" ]] && print -n "%F{cyan}${segment}%f "
}
setopt PROMPT_SUBST
PROMPT='$(dss_prompt_segment)'"$PROMPT"bash
dss_prompt_segment() {
local segment
segment="$(dss prompt)"
[[ -n "$segment" ]] && printf '\[\e[36m\]%s\[\e[0m\] ' "$segment"
}
PROMPT_COMMAND='__dss_ps1_segment="$(dss_prompt_segment)"'
PS1='${__dss_ps1_segment}'"$PS1"Pass --source in any of the recipes above (dss prompt --source) to include the "(repo)"/"(rule)"/"(global)" hint.
Output
DSS output is calm and scriptable: one accent color, a small glyph set (● ✓ ✗ !) instead of emoji, and no box-drawing. It respects NO_COLOR and automatically degrades to plain ASCII whenever stdout isn't a TTY, so piped or CI output stays greppable. Pass the global --json flag for machine-readable output instead: exactly one JSON object on stdout, { ok, command, data?, error? } — error is present when ok is false; data is present whenever the command produced one, including alongside error on a failure that still has diagnostics worth keeping (e.g. doctor's checks) — see AGENTS.md for the full contract and recipes.
Exit codes
DSS follows a deterministic four-code contract, so scripts and coding agents can branch on $? instead of scraping output:
| Code | Meaning |
| ---- | ------- |
| 0 | Success. |
| 1 | Operational failure — the command ran, but couldn't complete (e.g. an identity, key, or binding that isn't in the state you asked for). |
| 2 | Usage error — the invocation itself was malformed or incomplete (unknown command/option, a missing required argument, or a required prompt with no way to answer it non-interactively). |
| 130 | Cancelled — a prompt was closed (Ctrl+C, or stdin closed) before it was answered. |
Examples:
dss use nope # exit 1 — "nope" isn't an existing identity (operational, not a usage mistake)
DSS_NO_INPUT=1 dss use # exit 2 — missing the required identityName argument, and no TTY to prompt for it
dss new # Ctrl+C at the first prompt → exit 130Deprecated aliases (dss list, dss switch, dss add, ...) follow the exact same contract as the command they delegate to.
dss prompt is a deliberate, documented exception to this contract (see Shell prompt integration): as the one command meant to run on every shell prompt render, it always exits 0 — even when it can't resolve anything (a corrupt store, a git failure, ...). A prompt segment that could return 1 on a transient error would wreck the user's shell rather than just leaving the segment blank, so it degrades to empty output instead of following the 1/2 rows above.
For coding agents
DSS is safe to drive non-interactively: set DSS_NO_INPUT=1 (or pipe stdin) and pass --json
for a single machine-readable object on stdout instead of decorated output. See
AGENTS.md for the full non-interactive contract and a copy-paste recipe for
every command. A few examples:
dss ls --json # list identities
dss use work --json # switch the active identity
dss new --name work --email [email protected] --user "Jane Doe" --host github.com --key ed25519 --json -yDevelopment
Contributing
- Fork the repository
- Create a feature branch:
git checkout -b feature/amazing-feature - Make your changes with tests
- Run the test suite:
npm test - Submit a pull request
Local Development
# Clone and setup
git clone https://github.com/jalasem/dss.git
cd dss
npm install
# Development workflow
npm run dev # Run in development mode
npm run build # Build for production
npm run lint # Check code style
npm test # Run test suite
npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate coverage reportProject Structure
src/
├── index.ts # CLI entry point (Commander wiring, deprecated aliases)
├── commands/ # Thin, prompt-owning command handlers
│ ├── spaces.ts # addSpace, listSpaces, switchSpace, removeSpace, modifySpace
│ ├── binding.ts # dss link/unlink/status
│ ├── clone.ts # dss clone (rule/host-aware identity selection + auto-bind)
│ ├── batch.ts # dss config export/import
│ ├── keys.ts # dss key show/copy/rotate
│ ├── dashboard.ts # Bare-`dss` context dashboard
│ ├── doctor.ts # dss doctor (combined health check)
│ ├── rule.ts # dss rule add/ls/rm (directory rules → native includeIf)
│ ├── guard.ts # dss guard install/uninstall/check (pre-commit wrong-identity guard)
│ ├── firstRun.ts # Automatic first-run flow (replaces old `onboard`)
│ ├── completion.ts # Shell completion script generation
│ ├── prompt.ts # dss prompt (fast, never-error shell-prompt identity segment)
│ ├── prompts.ts # Shared/cancellation-safe prompt helpers
│ └── ui.ts # Calm output layer (colors, glyphs, tables, NO_COLOR)
├── core/ # Dependency-free domain types and lookups
│ ├── types.ts # ISpace/IIdentity/IStoreV2 and friends
│ ├── identity.ts # slugify, name validation, identity lookup
│ ├── rules.ts # matchRule (pure, longest-prefix directory match)
│ ├── gitUrl.ts # parseGitUrl (scp-like/ssh/https/git/local-path forms)
│ └── hosts.ts # Known Git hosts + key-settings links
└── infra/ # Filesystem, Git, and SSH side effects
├── store.ts # Config store (~/.dss/spaces/config.json, v1→v2 migration)
├── git.ts # active/identity gitconfig + global include (includeIf-first)
├── rules.ts # rules.gitconfig compiler (directory rules → includeIf)
├── identityResolution.ts # resolveAppliesHere (bound > rule > global) — shared by dashboard/doctor/guard
├── guard.ts # pre-commit hook script + git-path resolution + marker detection
├── ssh.ts # ssh-config (parse-don't-splice) and host access checks
├── keys.ts # SSH key generation (system ssh-keygen binary)
├── clipboard.ts # Platform-specific clipboard operations
├── repoBinding.ts # Repository-local Git conditional-include binding
└── gitClone.ts # git clone via execFile (keyed GIT_SSH_COMMAND, stderr relay/capture)
tests/
├── commands/ # Command-layer test suites
├── core/ # Core-layer test suites
├── infra/ # Infra-layer test suites
└── ... # CLI-level and integration test suitesUse Cases
- Freelancers & consultants — a separate identity per client, isolated keys and configs, quick switching.
- Enterprise developers — distinct work and personal accounts with different SSH keys.
- Open source contributors — personal vs. professional identities without juggling
~/.gitconfigby hand. - Students & educators — separate school and personal projects with different emails.
Acknowledgments
- Contributors: thank you to everyone who has contributed to this project.
- Open Source Community: for the tools and libraries that make this possible.
- Users: for feedback, bug reports, and feature requests that help improve DSS.
Made with ❤️ by developers, for developers
