@hobble-env/cli 3.0.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.
Files changed (47) hide show
  1. package/README.md +148 -0
  2. package/SKILL.md +91 -0
  3. package/dist/bin/hobble.d.ts +2 -0
  4. package/dist/bin/hobble.js +46 -0
  5. package/dist/commands/delete.d.ts +2 -0
  6. package/dist/commands/delete.js +43 -0
  7. package/dist/commands/env-pull.d.ts +2 -0
  8. package/dist/commands/env-pull.js +56 -0
  9. package/dist/commands/env-push.d.ts +2 -0
  10. package/dist/commands/env-push.js +135 -0
  11. package/dist/commands/get.d.ts +2 -0
  12. package/dist/commands/get.js +43 -0
  13. package/dist/commands/init.d.ts +2 -0
  14. package/dist/commands/init.js +70 -0
  15. package/dist/commands/list.d.ts +4 -0
  16. package/dist/commands/list.js +52 -0
  17. package/dist/commands/login.d.ts +2 -0
  18. package/dist/commands/login.js +223 -0
  19. package/dist/commands/logout.d.ts +2 -0
  20. package/dist/commands/logout.js +25 -0
  21. package/dist/commands/run.d.ts +2 -0
  22. package/dist/commands/run.js +40 -0
  23. package/dist/commands/set.d.ts +2 -0
  24. package/dist/commands/set.js +65 -0
  25. package/dist/commands/shell.d.ts +2 -0
  26. package/dist/commands/shell.js +88 -0
  27. package/dist/commands/switch.d.ts +2 -0
  28. package/dist/commands/switch.js +37 -0
  29. package/dist/commands/tags.d.ts +2 -0
  30. package/dist/commands/tags.js +33 -0
  31. package/dist/commands/whoami.d.ts +2 -0
  32. package/dist/commands/whoami.js +45 -0
  33. package/dist/lib/api.d.ts +10 -0
  34. package/dist/lib/api.js +88 -0
  35. package/dist/lib/browser-login.d.ts +38 -0
  36. package/dist/lib/browser-login.js +88 -0
  37. package/dist/lib/config.d.ts +29 -0
  38. package/dist/lib/config.js +102 -0
  39. package/dist/lib/env-format.d.ts +6 -0
  40. package/dist/lib/env-format.js +35 -0
  41. package/dist/lib/output.d.ts +9 -0
  42. package/dist/lib/output.js +36 -0
  43. package/dist/lib/project-config.d.ts +21 -0
  44. package/dist/lib/project-config.js +55 -0
  45. package/dist/lib/shell-prompt.d.ts +31 -0
  46. package/dist/lib/shell-prompt.js +101 -0
  47. package/package.json +29 -0
@@ -0,0 +1,88 @@
1
+ import { loadConfig, getApiUrl, getAuthCredential } from "./config.js";
2
+ import { debug } from "./output.js";
3
+ export class ApiError extends Error {
4
+ status;
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ this.name = "ApiError";
9
+ }
10
+ }
11
+ async function getHeaders() {
12
+ const credential = await getAuthCredential();
13
+ if (credential.type === "apiKey") {
14
+ return {
15
+ headers: {
16
+ "x-api-key": credential.token,
17
+ "Content-Type": "application/json",
18
+ },
19
+ credentialType: "apiKey",
20
+ };
21
+ }
22
+ return {
23
+ headers: {
24
+ Authorization: `Bearer ${credential.token}`,
25
+ "Content-Type": "application/json",
26
+ },
27
+ credentialType: "session",
28
+ };
29
+ }
30
+ let _baseUrlLogged = false;
31
+ async function getBaseUrl() {
32
+ const config = await loadConfig();
33
+ const url = getApiUrl(config);
34
+ if (!_baseUrlLogged) {
35
+ _baseUrlLogged = true;
36
+ if (process.env.HOBBLE_API_URL) {
37
+ debug(`Config: API URL from HOBBLE_API_URL (${url})`);
38
+ }
39
+ else if (config?.apiUrl) {
40
+ debug(`Config: API URL from ~/.config/hobble/config.json (${url})`);
41
+ }
42
+ else {
43
+ debug(`Config: API URL using default (${url})`);
44
+ }
45
+ }
46
+ return url;
47
+ }
48
+ async function request(method, path, body) {
49
+ const baseUrl = await getBaseUrl();
50
+ const { headers, credentialType } = await getHeaders();
51
+ const fullUrl = `${baseUrl}${path}`;
52
+ debug(`${method} ${fullUrl}`);
53
+ let res;
54
+ try {
55
+ res = await fetch(fullUrl, {
56
+ method,
57
+ headers,
58
+ body: body ? JSON.stringify(body) : undefined,
59
+ });
60
+ }
61
+ catch (err) {
62
+ debug(`Error: ${err.message}`);
63
+ throw new Error(`Could not reach the server at ${baseUrl}. Check your connection or run \`hobble login\` to update the URL.`);
64
+ }
65
+ debug(`${res.status} ${res.statusText}`);
66
+ if (res.status === 401) {
67
+ if (credentialType === "session") {
68
+ throw new ApiError(401, "Your session has expired. Run `hobble login` to sign in again.");
69
+ }
70
+ throw new ApiError(401, "API key is invalid or expired. Run `hobble login` to update.");
71
+ }
72
+ if (res.status === 403) {
73
+ throw new ApiError(403, "You don't have permission to perform this action.");
74
+ }
75
+ if (!res.ok) {
76
+ const data = await res.json().catch(() => ({}));
77
+ throw new ApiError(res.status, data.error ?? `Request failed with status ${res.status}`);
78
+ }
79
+ if (res.status === 204)
80
+ return {};
81
+ return res.json();
82
+ }
83
+ export const api = {
84
+ get: (path) => request("GET", path),
85
+ post: (path, body) => request("POST", path, body),
86
+ put: (path, body) => request("PUT", path, body),
87
+ delete: (path) => request("DELETE", path),
88
+ };
@@ -0,0 +1,38 @@
1
+ export interface DeviceCodeResponse {
2
+ device_code: string;
3
+ user_code: string;
4
+ verification_uri: string;
5
+ verification_uri_complete?: string;
6
+ expires_in: number;
7
+ interval?: number;
8
+ }
9
+ export interface DeviceTokenSuccess {
10
+ access_token: string;
11
+ /** Better-Auth may return Unix-seconds (number) or ISO 8601 (string). Normalized by `normalizeExpiresAt`. */
12
+ expires_at?: number | string;
13
+ /** Seconds until expiry (RFC 8693). Present when expires_at is absent. */
14
+ expires_in?: number;
15
+ }
16
+ export interface DeviceTokenError {
17
+ error: "authorization_pending" | "slow_down" | "access_denied" | "expired_token" | "invalid_grant" | string;
18
+ error_description?: string;
19
+ }
20
+ export declare class DeviceFlowDeniedError extends Error {
21
+ constructor();
22
+ }
23
+ export declare class DeviceFlowExpiredError extends Error {
24
+ constructor();
25
+ }
26
+ export declare function requestDeviceCode(apiUrl: string): Promise<DeviceCodeResponse>;
27
+ export interface PollOptions {
28
+ apiUrl: string;
29
+ deviceCode: string;
30
+ intervalSeconds: number;
31
+ /** Optional override for testing; defaults to global setTimeout. */
32
+ sleep?: (ms: number) => Promise<void>;
33
+ /** Optional abort signal for Ctrl+C. */
34
+ signal?: AbortSignal;
35
+ }
36
+ export declare function pollForToken(opts: PollOptions): Promise<DeviceTokenSuccess>;
37
+ /** Normalize Better-Auth's `expires_at` (which may be Unix-seconds or ISO) to ISO 8601. */
38
+ export declare function normalizeExpiresAt(value: number | string): string;
@@ -0,0 +1,88 @@
1
+ import { debug } from "./output.js";
2
+ export class DeviceFlowDeniedError extends Error {
3
+ constructor() {
4
+ super("Authorization denied.");
5
+ this.name = "DeviceFlowDeniedError";
6
+ }
7
+ }
8
+ export class DeviceFlowExpiredError extends Error {
9
+ constructor() {
10
+ super("The login code expired. Run `hobble login` to try again.");
11
+ this.name = "DeviceFlowExpiredError";
12
+ }
13
+ }
14
+ export async function requestDeviceCode(apiUrl) {
15
+ const url = `${apiUrl}/api/auth/device/code`;
16
+ debug(`POST ${url}`);
17
+ const res = await fetch(url, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({ client_id: "hobble-cli", scope: "openid profile email" }),
21
+ });
22
+ debug(`${res.status} ${res.statusText}`);
23
+ if (!res.ok) {
24
+ throw new Error(`Device code request failed (${res.status})`);
25
+ }
26
+ return (await res.json());
27
+ }
28
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+ export async function pollForToken(opts) {
30
+ const { apiUrl, deviceCode, signal } = opts;
31
+ const sleep = opts.sleep ?? defaultSleep;
32
+ let interval = Math.max(1, opts.intervalSeconds);
33
+ const url = `${apiUrl}/api/auth/device/token`;
34
+ while (true) {
35
+ if (signal?.aborted)
36
+ throw new Error("Login cancelled.");
37
+ await sleep(interval * 1000);
38
+ if (signal?.aborted)
39
+ throw new Error("Login cancelled.");
40
+ debug(`POST ${url} (poll, interval=${interval}s)`);
41
+ const res = await fetch(url, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({
45
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
46
+ device_code: deviceCode,
47
+ client_id: "hobble-cli",
48
+ }),
49
+ });
50
+ debug(`${res.status} ${res.statusText}`);
51
+ if (res.ok) {
52
+ return (await res.json());
53
+ }
54
+ let body;
55
+ try {
56
+ body = (await res.json());
57
+ }
58
+ catch {
59
+ throw new Error(`Token poll failed (${res.status}) and response was not JSON`);
60
+ }
61
+ debug(`poll error: ${body.error}`);
62
+ switch (body.error) {
63
+ case "authorization_pending":
64
+ continue;
65
+ case "slow_down":
66
+ interval += 5;
67
+ continue;
68
+ case "access_denied":
69
+ throw new DeviceFlowDeniedError();
70
+ case "expired_token":
71
+ throw new DeviceFlowExpiredError();
72
+ default:
73
+ throw new Error(body.error_description || body.error);
74
+ }
75
+ }
76
+ }
77
+ /** Normalize Better-Auth's `expires_at` (which may be Unix-seconds or ISO) to ISO 8601. */
78
+ export function normalizeExpiresAt(value) {
79
+ if (typeof value === "string") {
80
+ const ms = Date.parse(value);
81
+ if (!Number.isFinite(ms))
82
+ throw new Error(`Invalid expires_at string: ${value}`);
83
+ return new Date(ms).toISOString();
84
+ }
85
+ // Numeric — could be seconds or ms. Heuristic: anything < 10^12 is seconds.
86
+ const ms = value < 1e12 ? value * 1000 : value;
87
+ return new Date(ms).toISOString();
88
+ }
@@ -0,0 +1,29 @@
1
+ export type AuthCredential = {
2
+ type: "apiKey";
3
+ token: string;
4
+ } | {
5
+ type: "session";
6
+ token: string;
7
+ expiresAt: string;
8
+ };
9
+ export interface GlobalConfig {
10
+ apiUrl: string;
11
+ auth: AuthCredential;
12
+ }
13
+ export declare function loadConfig(): Promise<GlobalConfig | null>;
14
+ export declare function saveConfig(config: GlobalConfig): Promise<void>;
15
+ export declare function deleteConfig(): Promise<void>;
16
+ export declare function getAuthCredential(): Promise<AuthCredential>;
17
+ export declare function getApiUrl(config: GlobalConfig | null): string;
18
+ /**
19
+ * Normalize a user-supplied API URL. If the input lacks a scheme (no `://`),
20
+ * `https://` is assumed — typing `secrets.example.com` becomes
21
+ * `https://secrets.example.com`. Existing `http://` and `https://` prefixes
22
+ * are preserved so localhost development URLs keep working. Whitespace and a
23
+ * single trailing slash are also stripped for consistency.
24
+ *
25
+ * Empty input is returned unchanged so callers can decide how to treat it
26
+ * (typically by falling back to a default).
27
+ */
28
+ export declare function normalizeApiUrl(input: string): string;
29
+ export declare function requireConfig(): Promise<GlobalConfig>;
@@ -0,0 +1,102 @@
1
+ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CONFIG_DIR = join(homedir(), ".config", "hobble");
5
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
6
+ function isGlobalConfig(value) {
7
+ if (!value || typeof value !== "object")
8
+ return false;
9
+ const v = value;
10
+ if (typeof v.apiUrl !== "string")
11
+ return false;
12
+ if (!v.auth || typeof v.auth !== "object")
13
+ return false;
14
+ const a = v.auth;
15
+ if (a.type === "apiKey")
16
+ return typeof a.token === "string";
17
+ if (a.type === "session") {
18
+ return typeof a.token === "string" && typeof a.expiresAt === "string";
19
+ }
20
+ return false;
21
+ }
22
+ function migrateLegacy(parsed) {
23
+ if (typeof parsed.apiKey !== "string" || typeof parsed.apiUrl !== "string") {
24
+ return null;
25
+ }
26
+ return {
27
+ apiUrl: parsed.apiUrl,
28
+ auth: { type: "apiKey", token: parsed.apiKey },
29
+ };
30
+ }
31
+ export async function loadConfig() {
32
+ let raw;
33
+ try {
34
+ raw = await readFile(CONFIG_FILE, "utf-8");
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(raw);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ if (isGlobalConfig(parsed))
47
+ return parsed;
48
+ const migrated = migrateLegacy(parsed);
49
+ if (migrated) {
50
+ await saveConfig(migrated);
51
+ return migrated;
52
+ }
53
+ return null;
54
+ }
55
+ export async function saveConfig(config) {
56
+ await mkdir(CONFIG_DIR, { recursive: true });
57
+ await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
58
+ }
59
+ export async function deleteConfig() {
60
+ try {
61
+ await rm(CONFIG_FILE);
62
+ }
63
+ catch {
64
+ // already gone
65
+ }
66
+ }
67
+ export async function getAuthCredential() {
68
+ const envKey = process.env.HOBBLE_API_KEY;
69
+ if (envKey)
70
+ return { type: "apiKey", token: envKey };
71
+ const config = await loadConfig();
72
+ if (!config?.auth)
73
+ throw new Error("Not logged in. Run `hobble login` first.");
74
+ return config.auth;
75
+ }
76
+ export function getApiUrl(config) {
77
+ return process.env.HOBBLE_API_URL ?? config?.apiUrl ?? "http://localhost:3000";
78
+ }
79
+ /**
80
+ * Normalize a user-supplied API URL. If the input lacks a scheme (no `://`),
81
+ * `https://` is assumed — typing `secrets.example.com` becomes
82
+ * `https://secrets.example.com`. Existing `http://` and `https://` prefixes
83
+ * are preserved so localhost development URLs keep working. Whitespace and a
84
+ * single trailing slash are also stripped for consistency.
85
+ *
86
+ * Empty input is returned unchanged so callers can decide how to treat it
87
+ * (typically by falling back to a default).
88
+ */
89
+ export function normalizeApiUrl(input) {
90
+ const trimmed = input.trim();
91
+ if (trimmed === "")
92
+ return "";
93
+ const withScheme = trimmed.includes("://") ? trimmed : `https://${trimmed}`;
94
+ return withScheme.replace(/\/+$/, "");
95
+ }
96
+ export async function requireConfig() {
97
+ const config = await loadConfig();
98
+ if (!config) {
99
+ throw new Error("Not logged in. Run `hobble login` first.");
100
+ }
101
+ return config;
102
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Format a secret comment as `# `-prefixed lines for inclusion in a .env file.
3
+ *
4
+ * Mirror of src/lib/env-format.ts (CLI is a separate tsconfig package).
5
+ */
6
+ export declare function formatEnvComment(comment: string | null | undefined, maxWidth?: number): string[];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Format a secret comment as `# `-prefixed lines for inclusion in a .env file.
3
+ *
4
+ * Mirror of src/lib/env-format.ts (CLI is a separate tsconfig package).
5
+ */
6
+ export function formatEnvComment(comment, maxWidth = 80) {
7
+ if (!comment)
8
+ return [];
9
+ const out = [];
10
+ for (const rawLine of comment.split(/\r?\n/)) {
11
+ const line = rawLine.trimEnd();
12
+ if (line === "") {
13
+ out.push("#");
14
+ continue;
15
+ }
16
+ const limit = Math.max(1, maxWidth - 2);
17
+ const words = line.split(/\s+/).filter(Boolean);
18
+ let current = "";
19
+ for (const word of words) {
20
+ if (current === "") {
21
+ current = word;
22
+ }
23
+ else if (current.length + 1 + word.length <= limit) {
24
+ current += " " + word;
25
+ }
26
+ else {
27
+ out.push(`# ${current}`);
28
+ current = word;
29
+ }
30
+ }
31
+ if (current !== "")
32
+ out.push(`# ${current}`);
33
+ }
34
+ return out;
35
+ }
@@ -0,0 +1,9 @@
1
+ export declare function isTTY(): boolean;
2
+ export declare function info(msg: string): void;
3
+ export declare function success(msg: string): void;
4
+ export declare function warn(msg: string): void;
5
+ export declare function error(msg: string): void;
6
+ export declare function table(headers: string[], rows: string[][]): void;
7
+ export declare function setVerbose(v: boolean): void;
8
+ export declare function isVerbose(): boolean;
9
+ export declare function debug(msg: string): void;
@@ -0,0 +1,36 @@
1
+ import chalk from "chalk";
2
+ export function isTTY() {
3
+ return process.stdout.isTTY === true;
4
+ }
5
+ export function info(msg) {
6
+ process.stderr.write(chalk.blue("ℹ") + " " + msg + "\n");
7
+ }
8
+ export function success(msg) {
9
+ process.stderr.write(chalk.green("✓") + " " + msg + "\n");
10
+ }
11
+ export function warn(msg) {
12
+ process.stderr.write(chalk.yellow("⚠") + " " + msg + "\n");
13
+ }
14
+ export function error(msg) {
15
+ process.stderr.write(chalk.red("✗") + " " + msg + "\n");
16
+ }
17
+ export function table(headers, rows) {
18
+ const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
19
+ const separator = colWidths.map((w) => "─".repeat(w + 2)).join("┼");
20
+ const formatRow = (row) => row.map((cell, i) => ` ${(cell ?? "").padEnd(colWidths[i])} `).join("│");
21
+ process.stderr.write(formatRow(headers.map((h) => chalk.bold(h))) + "\n");
22
+ process.stderr.write(separator + "\n");
23
+ rows.forEach((row) => process.stderr.write(formatRow(row) + "\n"));
24
+ }
25
+ let _verbose = false;
26
+ export function setVerbose(v) {
27
+ _verbose = v;
28
+ }
29
+ export function isVerbose() {
30
+ return _verbose;
31
+ }
32
+ export function debug(msg) {
33
+ if (!_verbose)
34
+ return;
35
+ process.stderr.write(chalk.dim("[debug]") + " " + msg + "\n");
36
+ }
@@ -0,0 +1,21 @@
1
+ export interface ProjectConfig {
2
+ project: string;
3
+ environment: string;
4
+ }
5
+ export declare function loadProjectConfig(): Promise<ProjectConfig | null>;
6
+ export declare function saveProjectConfig(config: ProjectConfig): Promise<void>;
7
+ /**
8
+ * Returns true when the project's .gitignore already contains an entry for
9
+ * the project config file (PROJECT_CONFIG_FILE). Returns false when the
10
+ * .gitignore exists but does not mention it, or when .gitignore is missing.
11
+ */
12
+ export declare function isHobbleInGitignore(): Promise<boolean>;
13
+ export declare function addToGitignore(): Promise<void>;
14
+ export interface ResolvedContext {
15
+ project: string;
16
+ environment: string;
17
+ }
18
+ export declare function resolveContext(opts: {
19
+ project?: string;
20
+ env?: string;
21
+ }): Promise<ResolvedContext>;
@@ -0,0 +1,55 @@
1
+ import { readFile, writeFile, appendFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const PROJECT_CONFIG_FILE = ".hobble";
4
+ export async function loadProjectConfig() {
5
+ try {
6
+ const raw = await readFile(PROJECT_CONFIG_FILE, "utf-8");
7
+ return JSON.parse(raw);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ export async function saveProjectConfig(config) {
14
+ await writeFile(PROJECT_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
15
+ }
16
+ /**
17
+ * Returns true when the project's .gitignore already contains an entry for
18
+ * the project config file (PROJECT_CONFIG_FILE). Returns false when the
19
+ * .gitignore exists but does not mention it, or when .gitignore is missing.
20
+ */
21
+ export async function isHobbleInGitignore() {
22
+ const gitignorePath = join(process.cwd(), ".gitignore");
23
+ try {
24
+ const content = await readFile(gitignorePath, "utf-8");
25
+ return content.includes(PROJECT_CONFIG_FILE);
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export async function addToGitignore() {
32
+ const gitignorePath = join(process.cwd(), ".gitignore");
33
+ try {
34
+ const content = await readFile(gitignorePath, "utf-8");
35
+ if (content.includes(PROJECT_CONFIG_FILE))
36
+ return;
37
+ await appendFile(gitignorePath, `\n${PROJECT_CONFIG_FILE}\n`);
38
+ }
39
+ catch {
40
+ await writeFile(gitignorePath, `${PROJECT_CONFIG_FILE}\n`, "utf-8");
41
+ }
42
+ }
43
+ export async function resolveContext(opts) {
44
+ if (opts.project && opts.env) {
45
+ return { project: opts.project, environment: opts.env };
46
+ }
47
+ const config = await loadProjectConfig();
48
+ if (config) {
49
+ return {
50
+ project: opts.project ?? config.project,
51
+ environment: opts.env ?? config.environment,
52
+ };
53
+ }
54
+ throw new Error("No project configured. Run `hobble init` or pass `--project` and `--env`.");
55
+ }
@@ -0,0 +1,31 @@
1
+ export type ShellKind = "bash" | "zsh" | "other";
2
+ export declare function detectShellKind(shellPath: string | undefined): ShellKind;
3
+ export declare function hasStarship(pathEnv?: string | undefined): boolean;
4
+ export declare function buildBashRcfile(opts: {
5
+ starship: boolean;
6
+ }): string;
7
+ export declare function buildZshRc(opts: {
8
+ userZshrc: string;
9
+ starship: boolean;
10
+ }): string;
11
+ export interface SpawnPlan {
12
+ args: string[];
13
+ envOverrides: Record<string, string>;
14
+ cleanupPaths: string[];
15
+ }
16
+ export declare function buildHobbleWrapperScript(opts: {
17
+ nodePath: string;
18
+ scriptPath: string;
19
+ }): string;
20
+ export declare function writeHobbleBinDir(opts: {
21
+ nodePath: string;
22
+ scriptPath: string;
23
+ }): Promise<{
24
+ dir: string;
25
+ }>;
26
+ export declare function planSpawn(opts: {
27
+ kind: ShellKind;
28
+ home: string;
29
+ zdotdir?: string;
30
+ starship: boolean;
31
+ }): Promise<SpawnPlan>;
@@ -0,0 +1,101 @@
1
+ import { writeFile, mkdtemp, chmod } from "node:fs/promises";
2
+ import { statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, delimiter, join } from "node:path";
5
+ export function detectShellKind(shellPath) {
6
+ if (!shellPath)
7
+ return "other";
8
+ const name = basename(shellPath);
9
+ if (name === "bash")
10
+ return "bash";
11
+ if (name === "zsh")
12
+ return "zsh";
13
+ return "other";
14
+ }
15
+ export function hasStarship(pathEnv = process.env.PATH) {
16
+ if (!pathEnv)
17
+ return false;
18
+ for (const dir of pathEnv.split(delimiter)) {
19
+ if (!dir)
20
+ continue;
21
+ const candidate = join(dir, "starship");
22
+ try {
23
+ const s = statSync(candidate);
24
+ if (s.isFile() && (s.mode & 0o111) !== 0)
25
+ return true;
26
+ }
27
+ catch {
28
+ // ENOENT or permission — keep walking
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ export function buildBashRcfile(opts) {
34
+ const sourceLine = '[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"';
35
+ if (opts.starship) {
36
+ return [
37
+ sourceLine,
38
+ `__hobble_prompt() { printf '\\033[2m(hobble:%s)\\033[0m\\n' "$HOBBLE_SHELL"; }`,
39
+ `starship_precmd_user_func="__hobble_prompt"`,
40
+ "",
41
+ ].join("\n");
42
+ }
43
+ return [
44
+ sourceLine,
45
+ 'export PS1="(hobble:$HOBBLE_SHELL) $PS1"',
46
+ "",
47
+ ].join("\n");
48
+ }
49
+ export function buildZshRc(opts) {
50
+ const sourceLine = `[ -f "${opts.userZshrc}" ] && source "${opts.userZshrc}"`;
51
+ if (opts.starship) {
52
+ return [
53
+ sourceLine,
54
+ `__hobble_prompt() { print -P "%F{8}(hobble:$HOBBLE_SHELL)%f" }`,
55
+ `precmd_functions+=(__hobble_prompt)`,
56
+ "",
57
+ ].join("\n");
58
+ }
59
+ return [
60
+ sourceLine,
61
+ 'export PROMPT="(hobble:$HOBBLE_SHELL) $PROMPT"',
62
+ "",
63
+ ].join("\n");
64
+ }
65
+ export function buildHobbleWrapperScript(opts) {
66
+ const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`;
67
+ const cmd = opts.scriptPath.endsWith(".ts")
68
+ ? `exec npx tsx ${quote(opts.scriptPath)} "$@"`
69
+ : `exec ${quote(opts.nodePath)} ${quote(opts.scriptPath)} "$@"`;
70
+ return `#!/bin/sh\n${cmd}\n`;
71
+ }
72
+ export async function writeHobbleBinDir(opts) {
73
+ const dir = await mkdtemp(join(tmpdir(), "hobble-bin-"));
74
+ const wrapperPath = join(dir, "hobble");
75
+ await writeFile(wrapperPath, buildHobbleWrapperScript(opts), "utf-8");
76
+ await chmod(wrapperPath, 0o755);
77
+ return { dir };
78
+ }
79
+ export async function planSpawn(opts) {
80
+ if (opts.kind === "bash") {
81
+ const dir = await mkdtemp(join(tmpdir(), "hobble-bash-"));
82
+ const rcfile = join(dir, "rcfile");
83
+ await writeFile(rcfile, buildBashRcfile({ starship: opts.starship }), "utf-8");
84
+ return {
85
+ args: ["--rcfile", rcfile, "-i"],
86
+ envOverrides: {},
87
+ cleanupPaths: [dir],
88
+ };
89
+ }
90
+ if (opts.kind === "zsh") {
91
+ const dir = await mkdtemp(join(tmpdir(), "hobble-zsh-"));
92
+ const userZshrc = join(opts.zdotdir ?? opts.home, ".zshrc");
93
+ await writeFile(join(dir, ".zshrc"), buildZshRc({ userZshrc, starship: opts.starship }), "utf-8");
94
+ return {
95
+ args: [],
96
+ envOverrides: { ZDOTDIR: dir },
97
+ cleanupPaths: [dir],
98
+ };
99
+ }
100
+ return { args: [], envOverrides: {}, cleanupPaths: [] };
101
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@hobble-env/cli",
3
+ "version": "3.0.1",
4
+ "description": "CLI for managing secrets via hobble",
5
+ "type": "module",
6
+ "bin": {
7
+ "hobble": "./dist/bin/hobble.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "SKILL.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsx bin/hobble.ts"
16
+ },
17
+ "dependencies": {
18
+ "chalk": "^5.4.1",
19
+ "commander": "^13.1.0",
20
+ "dotenv": "^16.5.0",
21
+ "inquirer": "^12.6.0",
22
+ "open": "^10.2.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.15.2",
26
+ "tsx": "^4.19.4",
27
+ "typescript": "^5.8.3"
28
+ }
29
+ }