@hobble-env/cli 3.1.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,30 +34,30 @@ npm run hobble -- <command>
34
34
 
35
35
  ## Getting Started
36
36
 
37
- ### 1. Create an API Key
38
-
39
- Generate an API key from the dashboard under **Settings > API Keys**.
40
-
41
- ### 2. Log In
37
+ One command does it all:
42
38
 
43
39
  ```bash
44
- hobble login
40
+ hobble init --url https://secrets.your-company.dev
45
41
  ```
46
42
 
47
- You'll be prompted for your API key and the dashboard URL (defaults to `http://localhost:3000`).
43
+ If you aren't signed in to that instance yet (or your session expired), `init` starts the browser login first, then walks you through selecting a project and environment, saves the choice to a `.hobble` file in your current directory, and pins the instance URL in it. Drop that line in a project README and a new teammate is set up in one step.
48
44
 
49
- ### 3. Initialize a Project
45
+ Without `--url`, `init` targets your default instance (`http://localhost:3000` until you've logged in somewhere). You can switch project/environment later with:
50
46
 
51
47
  ```bash
52
- hobble init
48
+ hobble switch my-project/staging
53
49
  ```
54
50
 
55
- This walks you through selecting a project and environment, then saves the choice to a `.hobble` file in your current directory. You can switch later with:
51
+ ### Logging in separately
56
52
 
57
53
  ```bash
58
- hobble switch my-project/staging
54
+ hobble login # browser device flow; prompts for the URL
55
+ hobble login --url https://secrets.your-company.dev
56
+ hobble login --api-key # paste a long-lived API key instead
59
57
  ```
60
58
 
59
+ API keys are generated from the dashboard under **Settings > API Keys**. Credentials are stored per instance, so logging in to a second instance doesn't sign you out of the first.
60
+
61
61
  ## Commands
62
62
 
63
63
  ### Secrets
@@ -76,6 +76,50 @@ hobble delete DB_HOST # Delete a secret (with confirmation)
76
76
  hobble delete DB_HOST -y # Skip confirmation
77
77
  ```
78
78
 
79
+ ### Credentials (org-scoped)
80
+
81
+ Credentials are org-scoped shared logins and API secrets, distinct from the
82
+ per-environment secrets above. Note the noun: `hobble get DB_HOST` reads an
83
+ **environment secret**, while `hobble secret get stripe` (alias of
84
+ `hobble credential get stripe`) reads an **org credential**.
85
+
86
+ ```bash
87
+ hobble credential list # List credentials (no values)
88
+ hobble secret list --json # Same, via the alias, as JSON (includes tags)
89
+
90
+ hobble secret get stripe # Print a credential's decrypted value
91
+ hobble secret get stripe --field username # Print the username instead
92
+ hobble secret get stripe --json # Full object (name, username, url, value, tags)
93
+
94
+ hobble secret set stripe sk_live_123 --username ops@acme.com --url https://stripe.com
95
+ echo "sk_live_123" | hobble secret set stripe - # Value from stdin
96
+ hobble secret set stripe sk_test_9 --tag billing --tag prod # Attach org tags
97
+
98
+ hobble secret delete stripe
99
+ ```
100
+
101
+ Credential names are case-insensitive and unique **per organization** — no
102
+ `--project`/`--env` needed, but the CLI must know which org you mean:
103
+
104
+ - If your login/API key reaches exactly **one** org, it is used automatically.
105
+ - If it reaches **several** orgs, commands fail with an "ambiguous across
106
+ organizations" error listing the candidates — pass `--org <slug|id>`
107
+ (either the org slug or its id works):
108
+
109
+ ```bash
110
+ hobble secret get stripe --org acme-prod
111
+ ```
112
+
113
+ - A project/env-only scoped API token has **no** org-scoped credential
114
+ access and gets a clear error — create an org-scoped token instead.
115
+
116
+ CI usage: when stdout is not a TTY the value is printed raw (no decoration,
117
+ no trailing newline), so command substitution is clean:
118
+
119
+ ```bash
120
+ export STRIPE_KEY=$(hobble secret get stripe --field value)
121
+ ```
122
+
79
123
  ### Running Commands with Secrets
80
124
 
81
125
  Fetch secrets from the dashboard and inject them as environment variables into a subprocess:
@@ -122,24 +166,41 @@ hobble env push -i .env.local # Push from a custom path
122
166
  ### Account
123
167
 
124
168
  ```bash
125
- hobble whoami # Show current user and org info
126
- hobble logout # Remove stored credentials
169
+ hobble whoami # Show current user, org, and instance info
170
+ hobble logout # Remove credentials for the current instance
171
+ hobble logout --all # Remove credentials for every instance
127
172
  ```
128
173
 
129
174
  ## Configuration
130
175
 
131
176
  ### Global Config
132
177
 
133
- Stored at `~/.config/hobble/config.json` after `hobble login`. Contains your API key and dashboard URL.
178
+ Stored at `~/.config/hobble/config.json` after `hobble login` or `hobble init`. Holds your default instance URL and one credential per instance:
179
+
180
+ ```json
181
+ {
182
+ "apiUrl": "https://secrets.your-company.dev",
183
+ "credentials": {
184
+ "https://secrets.your-company.dev": { "type": "session", "token": "…", "expiresAt": "…" },
185
+ "http://localhost:3000": { "type": "apiKey", "token": "…" }
186
+ }
187
+ }
188
+ ```
134
189
 
135
190
  Override with environment variables:
136
191
 
137
- - `HOBBLE_API_KEY` -- override the stored API key
138
- - `HOBBLE_API_URL` -- override the stored dashboard URL
192
+ - `HOBBLE_API_KEY` -- override the stored credential
193
+ - `HOBBLE_API_URL` -- override the instance URL
139
194
 
140
195
  ### Project Config
141
196
 
142
- The `.hobble` file in your project directory stores the default project and environment. Add it to `.gitignore` -- `hobble init` will offer to do this for you.
197
+ The `.hobble` file in your project directory stores the default project and environment, and optionally pins an instance URL (written by `hobble init --url`):
198
+
199
+ ```json
200
+ { "project": "my-app", "environment": "staging", "apiUrl": "https://secrets.your-company.dev" }
201
+ ```
202
+
203
+ A pinned `apiUrl` takes precedence over the global default (but not over `HOBBLE_API_URL`), so different checkouts can talk to different instances. Credentials never live in `.hobble`. Add it to `.gitignore` -- `hobble init` will offer to do this for you.
143
204
 
144
205
  Override per-command with flags:
145
206
 
package/SKILL.md CHANGED
@@ -5,6 +5,10 @@ description: Use when a repository contains a `.hobble` file, references the hob
5
5
 
6
6
  # Using the hobble CLI
7
7
 
8
+ > **Living document.** This file is the canonical agent-facing reference for the hobble CLI surface. Per the gate in `AGENTS.md`, any feature work that changes the CLI surface (commands, flags, arguments, output formats, exit behavior, env vars, or config files) MUST update this file in the same change, and it must be verified current before every PR. Source of truth: `cli/bin/hobble.ts` and `cli/commands/*.ts`.
9
+
10
+ <!-- documented-version: 3.2.0 — keep in sync with cli/bin/hobble.ts -->
11
+
8
12
  ## Overview
9
13
 
10
14
  `hobble` fetches secrets from a remote hobble dashboard and makes them available to local processes. Secrets are never stored on disk unless a user explicitly runs `hobble env pull`. The safe default is in-memory injection via `hobble run`.
@@ -12,13 +16,15 @@ description: Use when a repository contains a `.hobble` file, references the hob
12
16
  ## Detecting hobble in a Repo
13
17
 
14
18
  Treat a repo as a hobble consumer if any of these are true:
15
- - A `.hobble` file exists in the repo root. It is JSON: `{ "project": "...", "environment": "..." }`.
19
+ - A `.hobble` file exists in the repo root. It is JSON: `{ "project": "...", "environment": "..." }`, optionally with an `"apiUrl"` pinning the instance for that directory.
16
20
  - `.gitignore` lists `.hobble`.
17
21
  - Scripts, Makefiles, or docs invoke `hobble ...`.
18
22
  - `package.json` has a script wrapping `hobble run`.
19
23
 
20
24
  If none of these are present, the repo is not a hobble consumer — do not introduce `hobble` without asking.
21
25
 
26
+ In this repo, the CLI runs from source: `npm run hobble -- <command>` (or `npx tsx cli/bin/hobble.ts <command>`). Elsewhere it is the globally installed `hobble` binary from `@hobble-env/cli`.
27
+
22
28
  ## Critical Rules
23
29
 
24
30
  1. **Never commit `.hobble`, `.env`, `.env.local`, or any file produced by `hobble env pull`.** Before running pull, confirm `.gitignore` covers the output path.
@@ -31,22 +37,83 @@ If none of these are present, the repo is not a hobble consumer — do not intro
31
37
  5. **Do not run interactive commands unprompted.** `hobble login`, `hobble init`, and `hobble switch` modify user or repo state. Ask the user to run them, or explicitly confirm before doing so.
32
38
  6. **Never run `hobble env push -y` without showing the diff first.** Omit `-y` (or run once without it) so the user can review create/update/delete counts.
33
39
 
34
- ## Quick Reference
35
-
36
- | Task | Command |
37
- |------|---------|
38
- | Run code with secrets injected | `hobble run -- <cmd>` |
39
- | List secret names (no values) | `hobble list` |
40
- | Read one value | `hobble get KEY` |
41
- | Create or update a secret | `printf '%s' "$v" \| hobble set KEY -` |
42
- | Delete a secret | `hobble delete KEY -y` |
43
- | Write `.env` file (use sparingly) | `hobble env pull` |
44
- | Upload local `.env` to dashboard | `hobble env push` (review diff before `-y`) |
45
- | Change bound project/env | `hobble switch <project>/<env>` |
46
- | Who am I / which org | `hobble whoami` |
40
+ ## Command Surface (v3.2.0)
41
+
42
+ Global options: `-v, --verbose` (debug output on stderr), `-V, --version`.
43
+
44
+ | Command | Arguments / flags | Notes |
45
+ |---------|-------------------|-------|
46
+ | `login` | `--url <url>`, `--api-key`, `--no-browser` | Interactive. Browser device flow by default; `--api-key` for a long-lived key. `--url` targets an instance and skips the URL prompt. Credentials are stored per instance URL in `~/.config/hobble/config.json`; the URL becomes the global default. |
47
+ | `logout` | `--all` | Removes stored credentials for the effective instance only; other instances stay logged in. `--all` removes every credential. |
48
+ | `whoami` | — | User, org, role, effective instance URL, and auth type (api key vs. session + expiry). |
49
+ | `init` | `--url <url>` | Interactive one-stop setup: signs in first when not authenticated (or session expired) against the effective instance, then selects project/env, writes `.hobble`, offers to gitignore it. `--url` pins the instance into `.hobble` (`"apiUrl"`). Non-TTY without credentials exits 1 with `Not logged in to <url>`. |
50
+ | `switch <project>/<env>` | — | Rewrites `.hobble` after validating both exist. Preserves an `"apiUrl"` pin. |
51
+ | `list` | `--project`, `--env`, `--json`, `--tag <name>` (repeatable) | Names + tags + dates, never values. `--tag` is an AND filter, case-insensitive. |
52
+ | `get <key>` | `--project`, `--env`, `--json` | Decrypted value. Output shape depends on TTY — see Composition. |
53
+ | `set <key> [value\|-]` | `--project`, `--env`, `--comment <text>`, `--tag <name>` (repeatable) | Create-or-update. `-` or omitted value reads stdin (trailing whitespace trimmed). `--tag` **replaces** all existing tags. `--comment ""` clears the comment. |
54
+ | `tags` | `--project`, `--env`, `--json` | Tags available to the project (name + color). `--env` accepted but unused. |
55
+ | `delete <key>` | `--project`, `--env`, `-y` | Confirmation prompt unless `-y`. |
56
+ | `run <cmd...>` | `--project`, `--env` | Injects all secrets into a subprocess (in-memory, `shell: true`). Use `--` before the command so its flags aren't parsed by hobble. Exits with the child's exit code. |
57
+ | `shell` | `--project`, `--env` | Interactive subshell with secrets injected. Sets `HOBBLE_PROJECT`, `HOBBLE_ENVIRONMENT`, `HOBBLE_SHELL=<project>/<env>`; prefixes bash/zsh prompts. Interactive — don't run unprompted. |
58
+ | `env pull` | `--project`, `--env`, `--force`, `-o <path>` (default `.env`) | Writes plaintext secrets + comments to disk. Refuses to overwrite without `--force`. Use sparingly (Rule 2). |
59
+ | `env push` | `--project`, `--env`, `-y`, `-i <path>` (default `.env`) | Diffs file against server, prints create/update/delete lists, confirms unless `-y`. **An empty value (`KEY=`) deletes that secret on the server.** Contiguous `# ...` lines above a key become its comment; comments are only sent when changed. |
60
+ | `credential list` | `--org <slug\|id>`, `--json` | Org-scoped credentials (alias: `secret list`). Names/usernames/URLs, never values. |
61
+ | `credential get <name>` | `--org`, `--field <value\|username\|url>`, `--json` | Decrypted credential (alias: `secret get`). Name match is case-insensitive. Non-TTY output is the raw field value only. |
62
+ | `credential set <name> [value\|-]` | `--org`, `--username`, `--url`, `--comment <text>`, `--tag <name>` (repeatable) | Create-or-update (alias: `secret set`). `-` or omitted value reads stdin. `--tag` **replaces** all existing tags. |
63
+ | `credential delete <name>` | `--org` | Deletes by case-insensitive name (alias: `secret delete`). |
47
64
 
48
65
  All data commands accept `--project <name> --env <name>` to override `.hobble` for a single invocation. Prefer flags over `hobble switch` for one-off reads.
49
66
 
67
+ ## Credentials vs Environment Secrets
68
+
69
+ `hobble get KEY` reads a **per-environment secret** (project/env-scoped, used as ENV vars). `hobble secret get NAME` — alias of `hobble credential get NAME` — reads an **org-scoped credential** (shared logins/API details, not bound to any environment). Do not confuse the two nouns.
70
+
71
+ Credential names are case-insensitive and unique per organization. Credential commands take no `--project`/`--env`; when the login/API key reaches more than one org, pass `--org <slug|id>` (an ambiguity error lists the candidate orgs). Project/env-only scoped tokens cannot access credentials — an org-scoped token is required. In CI, non-TTY output of `hobble secret get` is the raw value only: `export STRIPE_KEY=$(hobble secret get stripe)`.
72
+
73
+ ## Composition: Retrieving Items for Other Commands
74
+
75
+ The CLI is designed to be piped. The contract:
76
+
77
+ - **stdout carries only data** — JSON (with `--json`) or a raw secret value (`get` when piped).
78
+ - **stderr carries everything human-facing** — tables, `✓`/`ℹ`/`⚠` status lines, and `--verbose` debug output.
79
+
80
+ So `--json` output and `2>/dev/null`-free piping are always safe; you never need to strip decoration from stdout.
81
+
82
+ `hobble get KEY` is TTY-sensitive:
83
+ - Piped / captured (non-TTY): writes the **raw value only, no trailing newline** — safe for command substitution.
84
+ - Interactive terminal: prints `KEY=value` plus a `# tags: ...` line.
85
+
86
+ Patterns:
87
+
88
+ ```bash
89
+ # Feed a value directly to a consumer — never through echo or a variable you print
90
+ hobble get DB_PASS | docker login --password-stdin -u "$user"
91
+
92
+ # Enumerate keys for scripting
93
+ hobble list --json | jq -r '.[].key'
94
+
95
+ # Keys carrying a given tag (server-side list, client-side AND filter)
96
+ hobble list --tag database --json | jq -r '.[].key'
97
+
98
+ # Structured single-secret read: {"key", "value", "tags": [...]}
99
+ hobble get DB_HOST --json | jq -r '.value'
100
+
101
+ # Tag inventory for a project
102
+ hobble tags --json | jq -r '.[].name'
103
+
104
+ # Copy one secret between environments without it touching disk or argv
105
+ hobble get API_KEY --env staging | hobble set API_KEY - --env production
106
+ ```
107
+
108
+ JSON shapes:
109
+ - `list --json`: array of `{ id, key, createdAt, updatedAt, tags: [{ id, name, color }] }` — no values.
110
+ - `get --json`: `{ key, value, tags: [name, ...] }`.
111
+ - `tags --json`: array of `{ id, name, color }`.
112
+ - `credential list --json`: array of `{ id, name, username, url, comment, createdAt, updatedAt, tags: [{ id, name, color }] }` — no values.
113
+ - `credential get --json`: `{ name, username, url, value, comment, tags: [name, ...] }`.
114
+
115
+ All commands exit `0` on success and `1` on any failure (not found, auth, network), with the reason on stderr.
116
+
50
117
  ## Context Resolution
51
118
 
52
119
  Each data command needs a `project` and `environment`, resolved in order:
@@ -58,9 +125,11 @@ If you hit that error, stop and ask the user. Do not run `hobble init` on their
58
125
 
59
126
  ## Authentication
60
127
 
61
- - Stored config: `~/.config/hobble/config.json` (written by `hobble login`).
128
+ - Stored config: `~/.config/hobble/config.json` (written by `hobble login` / `hobble init`). Shape: `{ "apiUrl": "<default instance>", "credentials": { "<instance url>": { "type": "apiKey" | "session", ... } } }` — one credential per instance URL, so logins to multiple instances coexist. Older single-credential config files migrate automatically on first read.
129
+ - Instance URL resolution order: `HOBBLE_API_URL` env var > `"apiUrl"` in `.hobble` > global default in config.json > `http://localhost:3000`. The credential used is the one stored for the resolved URL (`HOBBLE_API_KEY` overrides everything).
62
130
  - Overrides via env vars: `HOBBLE_API_KEY`, `HOBBLE_API_URL`. Prefer these in CI.
63
- - A 401 error means the key is stale — tell the user to run `hobble login` rather than re-running it yourself.
131
+ - A 401 means the credential is stale or expired — tell the user to run `hobble login` rather than re-running it yourself. `hobble whoami` shows the effective instance, auth type, and session expiry.
132
+ - `HOBBLE_ASSUME_TTY=1` makes `init` treat piped stdin as interactive (test harness escape hatch — do not use it to bypass Rule 5).
64
133
 
65
134
  ## Common Mistakes
66
135
 
@@ -68,10 +137,13 @@ If you hit that error, stop and ask the user. Do not run `hobble init` on their
68
137
  |---------|------------------|
69
138
  | `hobble set KEY "mysecret"` | `printf '%s' "$v" \| hobble set KEY -` — args leak to shell history |
70
139
  | `hobble env pull` to "inspect" secrets | Use `hobble list` for keys; `hobble get KEY` piped to the consumer for one value |
140
+ | Parsing `hobble list` table output | Tables go to stderr and aren't stable. Use `--json` + `jq` |
141
+ | `hobble set KEY - --tag new` to "add" a tag | `--tag` replaces the full tag set — pass every tag the secret should keep |
142
+ | Pushing a `.env` containing `KEY=` lines | Empty value = **delete on server**. Review the diff (no `-y`) before confirming |
71
143
  | `git add -A` after `hobble env pull` | Verify `.gitignore`, then stage files by name |
72
144
  | Running `hobble login` / `hobble init` to "set things up" | Ask the user — both are interactive and user-scoped |
73
145
  | Committing `.hobble` because it has no secrets | It binds the repo to a project/env and should always be gitignored |
74
- | `hobble env push -y` on a dirty `.env` | Run without `-y` first to see the create/update/delete diff |
146
+ | `hobble run npm test -- --grep foo` flag confusion | Put `--` after `run`: `hobble run -- npm test` so hobble doesn't eat the flags |
75
147
 
76
148
  ## Running Tests That Need Secrets
77
149
 
@@ -89,3 +161,13 @@ hobble env pull && npm test
89
161
  ## When hobble Is Missing
90
162
 
91
163
  If `hobble` is not installed but the repo needs it, tell the user rather than guessing. Installation is user-specific (global npm vs. per-repo), and the dashboard URL and API key must be set up interactively via `hobble login`.
164
+
165
+ ## Maintaining This Document
166
+
167
+ When CLI feature work lands, update in the same change:
168
+ 1. The **Command Surface** table (new command, flag, argument, or default).
169
+ 2. **Composition** (any change to stdout/stderr behavior, JSON shapes, TTY handling, or exit codes — these are load-bearing for scripts).
170
+ 3. **Critical Rules / Common Mistakes** if the change creates a new way to leak a secret or a new footgun.
171
+ 4. The version marker at the top of this file (match `cli/bin/hobble.ts`).
172
+
173
+ Verify claims against the source, not memory: `cli/bin/hobble.ts` registers commands; each `cli/commands/*.ts` defines its flags; `cli/lib/output.ts` defines the stdout/stderr contract; `cli/lib/config.ts` and `cli/lib/api.ts` define auth and env-var overrides.
@@ -10,6 +10,7 @@ import { listCommand } from "../commands/list.js";
10
10
  import { getCommand } from "../commands/get.js";
11
11
  import { setCommand } from "../commands/set.js";
12
12
  import { tagsCommand } from "../commands/tags.js";
13
+ import { credentialCommand } from "../commands/credential.js";
13
14
  import { deleteCommand } from "../commands/delete.js";
14
15
  import { runCommand } from "../commands/run.js";
15
16
  import { shellCommand } from "../commands/shell.js";
@@ -19,7 +20,7 @@ const program = new Command();
19
20
  program
20
21
  .name("hobble")
21
22
  .description("CLI for managing secrets via the secret-management-dash API")
22
- .version("3.0.1")
23
+ .version("3.3.0")
23
24
  .enablePositionalOptions()
24
25
  .option("-v, --verbose", "Show debug output for troubleshooting");
25
26
  program.hook("preAction", (thisCommand) => {
@@ -36,6 +37,7 @@ program.addCommand(listCommand);
36
37
  program.addCommand(getCommand);
37
38
  program.addCommand(setCommand);
38
39
  program.addCommand(tagsCommand);
40
+ program.addCommand(credentialCommand);
39
41
  program.addCommand(deleteCommand);
40
42
  program.addCommand(runCommand);
41
43
  program.addCommand(shellCommand);
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const credentialCommand: Command;
@@ -0,0 +1,199 @@
1
+ import { Command } from "commander";
2
+ import { api, ApiError } from "../lib/api.js";
3
+ import { success, error, isTTY, table } from "../lib/output.js";
4
+ import { readStdin } from "../lib/stdin.js";
5
+ /**
6
+ * Render credential-specific API errors:
7
+ * - multi-org 400 → list the candidate orgs (`name (slug)`, id fallback when
8
+ * slug is null) and point at --org;
9
+ * - zero-org 403 → surface the server's distinct "no org-scoped credential
10
+ * access" message (NOT an ambiguity prompt); falls back to the canned 403
11
+ * message when the body is absent (older server).
12
+ */
13
+ function renderCredentialError(err) {
14
+ if (err instanceof ApiError) {
15
+ const body = (err.body ?? {});
16
+ if (err.status === 400 && Array.isArray(body.candidates) && body.candidates.length > 0) {
17
+ const list = body.candidates
18
+ .map((c) => `${c.name} (${c.slug ?? c.id})`)
19
+ .join(", ");
20
+ return `Credential names are ambiguous across organizations — pass --org <slug|id>. Candidates: ${list}`;
21
+ }
22
+ if (err.status === 403 &&
23
+ typeof body.error === "string" &&
24
+ body.error.includes("no org-scoped credential access")) {
25
+ return body.error;
26
+ }
27
+ }
28
+ return err.message;
29
+ }
30
+ function orgQuery(org) {
31
+ return org ? `?org=${encodeURIComponent(org)}` : "";
32
+ }
33
+ async function listCredentials(org) {
34
+ const res = (await api.get(`/api/v1/credentials${orgQuery(org)}`));
35
+ return res.data;
36
+ }
37
+ /**
38
+ * Case-INSENSITIVE name match — `credential_org_name_unique` is a per-org
39
+ * lower(name) index, so at most one credential matches per org and `STRIPE`
40
+ * must find `stripe`. (Deliberately not the exact `===` match the env-secret
41
+ * `get` uses; env keys are exact-case ENV names, credential names are not.)
42
+ */
43
+ function findByName(items, name) {
44
+ const lower = name.toLowerCase();
45
+ return items.find((c) => c.name.toLowerCase() === lower);
46
+ }
47
+ const listSubcommand = new Command("list")
48
+ .description("List credentials in the organization")
49
+ .option("--org <slugOrId>", "Organization (slug or id) when more than one is reachable")
50
+ .option("--json", "Output as JSON")
51
+ .action(async (opts) => {
52
+ try {
53
+ const credentials = await listCredentials(opts.org);
54
+ if (opts.json) {
55
+ process.stdout.write(JSON.stringify(credentials, null, 2) + "\n");
56
+ return;
57
+ }
58
+ if (credentials.length === 0) {
59
+ console.log("No credentials found.");
60
+ return;
61
+ }
62
+ table(["Name", "Username", "URL", "Updated"], credentials.map((c) => [
63
+ c.name,
64
+ c.username ?? "",
65
+ c.url ?? "",
66
+ new Date(c.updatedAt).toLocaleDateString(),
67
+ ]));
68
+ }
69
+ catch (err) {
70
+ error(renderCredentialError(err));
71
+ process.exit(1);
72
+ }
73
+ });
74
+ const getSubcommand = new Command("get")
75
+ .description("Get a decrypted credential value")
76
+ .argument("<name>", "Credential name (case-insensitive)")
77
+ .option("--org <slugOrId>", "Organization (slug or id) when more than one is reachable")
78
+ .option("--field <field>", "Field to output: value, username, or url", "value")
79
+ .option("--json", "Output the full credential as JSON")
80
+ .action(async (name, opts) => {
81
+ try {
82
+ const field = opts.field;
83
+ if (!["value", "username", "url"].includes(field)) {
84
+ error(`Invalid --field "${field}". Must be value, username, or url.`);
85
+ process.exit(1);
86
+ }
87
+ const match = findByName(await listCredentials(opts.org), name);
88
+ if (!match) {
89
+ error(`Credential "${name}" not found.`);
90
+ process.exit(1);
91
+ }
92
+ const res = (await api.get(`/api/v1/credentials/${match.id}`));
93
+ const cred = res.data;
94
+ if (opts.json) {
95
+ process.stdout.write(JSON.stringify({
96
+ name: cred.name,
97
+ username: cred.username,
98
+ url: cred.url,
99
+ value: cred.value,
100
+ comment: cred.comment,
101
+ tags: (cred.tags ?? []).map((t) => t.name),
102
+ }, null, 2) + "\n");
103
+ return;
104
+ }
105
+ const selected = field === "value" ? cred.value : field === "username" ? cred.username : cred.url;
106
+ if (selected == null) {
107
+ error(`Credential "${cred.name}" has no ${field}.`);
108
+ process.exit(1);
109
+ }
110
+ if (isTTY()) {
111
+ const label = field === "value" ? cred.name : `${cred.name}.${field}`;
112
+ console.log(`${label}=${selected}`);
113
+ }
114
+ else {
115
+ // CI-safe: raw value only, no decoration, no trailing newline — so
116
+ // `$(hobble secret get NAME)` substitutes cleanly.
117
+ process.stdout.write(selected);
118
+ }
119
+ }
120
+ catch (err) {
121
+ error(renderCredentialError(err));
122
+ process.exit(1);
123
+ }
124
+ });
125
+ const setSubcommand = new Command("set")
126
+ .description("Create or update a credential")
127
+ .argument("<name>", "Credential name (case-insensitive)")
128
+ .argument("[value]", "Credential value (use - to read from stdin)")
129
+ .option("--org <slugOrId>", "Organization (slug or id) when more than one is reachable")
130
+ .option("--username <username>", "Login/identifier for this credential")
131
+ .option("--url <url>", "Related URL")
132
+ .option("--comment <text>", "Comment (use empty string to clear)")
133
+ .option("--tag <name>", "Tag to attach (repeatable; replaces existing tags)", (val, prev) => prev.concat(val), [])
134
+ .action(async (name, rawValue, opts) => {
135
+ try {
136
+ let value;
137
+ if (rawValue === "-" || rawValue === undefined) {
138
+ value = await readStdin();
139
+ }
140
+ else {
141
+ value = rawValue;
142
+ }
143
+ // The same single-org resolution gates create-vs-update: the list call
144
+ // below 400s on multi-org callers without --org, so a write can never
145
+ // target an arbitrarily-chosen org.
146
+ const existing = findByName(await listCredentials(opts.org), name);
147
+ const tags = opts.tag && opts.tag.length ? opts.tag : undefined;
148
+ const fields = { value };
149
+ if (opts.username !== undefined)
150
+ fields.username = opts.username;
151
+ if (opts.url !== undefined)
152
+ fields.url = opts.url;
153
+ if (opts.comment !== undefined)
154
+ fields.comment = opts.comment;
155
+ if (tags)
156
+ fields.tags = tags;
157
+ if (existing) {
158
+ await api.put(`/api/v1/credentials/${existing.id}`, fields);
159
+ success(`Updated "${existing.name}"`);
160
+ }
161
+ else {
162
+ await api.post(`/api/v1/credentials${orgQuery(opts.org)}`, {
163
+ name,
164
+ ...fields,
165
+ });
166
+ success(`Created "${name}"`);
167
+ }
168
+ }
169
+ catch (err) {
170
+ error(renderCredentialError(err));
171
+ process.exit(1);
172
+ }
173
+ });
174
+ const deleteSubcommand = new Command("delete")
175
+ .description("Delete a credential")
176
+ .argument("<name>", "Credential name (case-insensitive)")
177
+ .option("--org <slugOrId>", "Organization (slug or id) when more than one is reachable")
178
+ .action(async (name, opts) => {
179
+ try {
180
+ const match = findByName(await listCredentials(opts.org), name);
181
+ if (!match) {
182
+ error(`Credential "${name}" not found.`);
183
+ process.exit(1);
184
+ }
185
+ await api.delete(`/api/v1/credentials/${match.id}`);
186
+ success(`Deleted "${match.name}"`);
187
+ }
188
+ catch (err) {
189
+ error(renderCredentialError(err));
190
+ process.exit(1);
191
+ }
192
+ });
193
+ export const credentialCommand = new Command("credential")
194
+ .alias("secret")
195
+ .description("Manage org-scoped credentials (shared logins/API secrets — distinct from per-environment secrets)")
196
+ .addCommand(listSubcommand)
197
+ .addCommand(getSubcommand)
198
+ .addCommand(setSubcommand)
199
+ .addCommand(deleteSubcommand);
@@ -1,12 +1,21 @@
1
1
  import { Command } from "commander";
2
2
  import inquirer from "inquirer";
3
- import { api } from "../lib/api.js";
4
- import { saveProjectConfig, addToGitignore, isHobbleInGitignore, } from "../lib/project-config.js";
3
+ import { api, setApiUrlOverride } from "../lib/api.js";
4
+ import { ensureAuthenticated } from "../lib/auth-flow.js";
5
+ import { loadProjectConfig, saveProjectConfig, addToGitignore, isHobbleInGitignore, } from "../lib/project-config.js";
5
6
  import { success, error } from "../lib/output.js";
6
7
  export const initCommand = new Command("init")
7
- .description("Configure project and environment for this directory")
8
- .action(async () => {
8
+ .description("Configure project and environment for this directory (signs you in first if needed)")
9
+ .option("--url <url>", "Hobble instance URL — logs in against it and pins it in .hobble")
10
+ .action(async (opts) => {
9
11
  try {
12
+ // One-stop setup: authenticate (or re-authenticate) against the
13
+ // effective instance before touching the API.
14
+ const apiUrl = await ensureAuthenticated({ url: opts.url });
15
+ // Pin every API call in this run to the instance we just verified —
16
+ // .hobble doesn't exist yet to carry the override.
17
+ setApiUrlOverride(apiUrl);
18
+ const existing = await loadProjectConfig();
10
19
  // Fetch projects
11
20
  const projectData = (await api.get("/api/v1/projects"));
12
21
  if (projectData.projects.length === 0) {
@@ -42,9 +51,13 @@ export const initCommand = new Command("init")
42
51
  })),
43
52
  },
44
53
  ]);
54
+ // Pin the instance in .hobble when --url named one explicitly, and
55
+ // preserve a pin a previous init/switch already wrote.
56
+ const pinnedUrl = opts.url ? apiUrl : existing?.apiUrl;
45
57
  await saveProjectConfig({
46
58
  project: selectedProject.name,
47
59
  environment: envName,
60
+ ...(pinnedUrl ? { apiUrl: pinnedUrl } : {}),
48
61
  });
49
62
  // Offer to add .hobble to .gitignore — but only if it's not already there
50
63
  const alreadyIgnored = await isHobbleInGitignore();
@@ -61,7 +74,9 @@ export const initCommand = new Command("init")
61
74
  await addToGitignore();
62
75
  }
63
76
  }
64
- success(`Configured: ${selectedProject.name}/${envName}`);
77
+ success(pinnedUrl
78
+ ? `Configured: ${selectedProject.name}/${envName} @ ${pinnedUrl}`
79
+ : `Configured: ${selectedProject.name}/${envName}`);
65
80
  }
66
81
  catch (err) {
67
82
  error(err.message);