From fe3b97bf846476b2abd5a524cb373c6d3c2608c5 Mon Sep 17 00:00:00 2001 From: KeKs0r Date: Tue, 17 Mar 2026 18:34:44 +0800 Subject: [PATCH 1/4] Add named environments feature for managing multiple ClickHouse connections Introduces environment management system allowing users to save, list, and switch between multiple ClickHouse connection configurations. New `chcli env` subcommand supports add/list/remove/show/use operations. Users can now specify an environment via --env/-e flag or set a default for the current folder. Connection resolution follows this priority: CLI flags > named environment > environment variables > defaults. Co-Authored-By: Claude Haiku 4.5 --- src/cli.test.ts | 11 +++ src/cli.ts | 5 ++ src/client.test.ts | 61 ++++++++++++++++ src/client.ts | 30 ++++++-- src/config.test.ts | 141 ++++++++++++++++++++++++++++++++++++ src/config.ts | 95 +++++++++++++++++++++++++ src/env.test.ts | 126 ++++++++++++++++++++++++++++++++ src/env.ts | 174 +++++++++++++++++++++++++++++++++++++++++++++ src/run.ts | 34 ++++++++- 9 files changed, 672 insertions(+), 5 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/env.test.ts create mode 100644 src/env.ts diff --git a/src/cli.test.ts b/src/cli.test.ts index a2981bb..026068e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -66,5 +66,16 @@ describe("parseCliArgs", () => { expect(config.file).toBeUndefined(); expect(config.host).toBeUndefined(); expect(config.format).toBeUndefined(); + expect(config.env).toBeUndefined(); + }); + + test("parses --env flag", () => { + const config = parseCliArgs(["--env", "prod", "-q", "SELECT 1"]); + expect(config.env).toBe("prod"); + }); + + test("parses -e shorthand for --env", () => { + const config = parseCliArgs(["-e", "staging", "-q", "SELECT 1"]); + expect(config.env).toBe("staging"); }); }); diff --git a/src/cli.ts b/src/cli.ts index 7698ea4..0fe8c74 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ const options = { password: { type: "string" }, database: { type: "string", short: "d" }, secure: { type: "boolean", short: "s", default: false }, + env: { type: "string", short: "e" }, format: { type: "string", short: "F" }, time: { type: "boolean", short: "t", default: false }, verbose: { type: "boolean", short: "v", default: false }, @@ -44,11 +45,15 @@ Connection: --password Password (env: CLICKHOUSE_PASSWORD, default: "") -d, --database Database (env: CLICKHOUSE_DATABASE or CLICKHOUSE_DB, default: default) -s, --secure Use HTTPS (env: CLICKHOUSE_SECURE) + -e, --env Use a named environment (overrides folder default) CLICKHOUSE_URL is also supported (e.g. https://host:8443) and will be used for host, port, secure, and password if the individual env vars are not set. + Environments can be configured with \`chcli env add \`. + Set a default environment for the current folder with \`chcli env use \`. + Output: -F, --format Output format (json, jsonl, csv, tsv, pretty, vertical, markdown, sql) -t, --time Print execution time to stderr diff --git a/src/client.test.ts b/src/client.test.ts index b49d7a3..25ddd8f 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -135,3 +135,64 @@ describe("resolveConnectionConfig", () => { }); }); }); + +describe("resolveConnectionConfig with named environment", () => { + const emptyConfig = {} as Parameters[0]; + const emptyEnv = {}; + + test("named environment provides connection settings", () => { + const result = resolveConnectionConfig(emptyConfig, emptyEnv, { + host: "named.host.com", + port: "9000", + user: "named_user", + password: "named_pass", + database: "named_db", + secure: true, + }); + expect(result).toEqual({ + url: "https://named.host.com:9000", + username: "named_user", + password: "named_pass", + database: "named_db", + }); + }); + + test("named environment takes precedence over env vars", () => { + const result = resolveConnectionConfig( + emptyConfig, + { CLICKHOUSE_HOST: "env-host", CLICKHOUSE_USER: "env_user" }, + { host: "named.host.com", user: "named_user" }, + ); + expect(result.url).toContain("named.host.com"); + expect(result.username).toBe("named_user"); + }); + + test("CLI flags take precedence over named environment", () => { + const config = { + host: "flag-host", + user: "flag_user", + } as Parameters[0]; + + const result = resolveConnectionConfig(config, emptyEnv, { + host: "named.host.com", + user: "named_user", + }); + expect(result.url).toContain("flag-host"); + expect(result.username).toBe("flag_user"); + }); + + test("named environment url is parsed for host/port/secure", () => { + const result = resolveConnectionConfig(emptyConfig, emptyEnv, { + url: "https://url-host.com:8443", + }); + expect(result.url).toBe("https://url-host.com:8443"); + }); + + test("named environment host takes precedence over named url", () => { + const result = resolveConnectionConfig(emptyConfig, emptyEnv, { + url: "https://url-host.com:8443", + host: "explicit-host.com", + }); + expect(result.url).toContain("explicit-host.com"); + }); +}); diff --git a/src/client.ts b/src/client.ts index e0aa7b1..e316454 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ import { createClient } from "@clickhouse/client"; import type { CliConfig } from "./cli"; +import type { Environment } from "./config"; export function parseClickHouseUrl(raw: string) { const url = new URL(raw); @@ -14,17 +15,32 @@ export function parseClickHouseUrl(raw: string) { export function resolveConnectionConfig( config: CliConfig, env: Record = process.env, + namedEnv?: Environment, ) { + // Parse CLICKHOUSE_URL from env vars const parsed = env.CLICKHOUSE_URL ? parseClickHouseUrl(env.CLICKHOUSE_URL) : undefined; + // Parse url from named environment + const namedParsed = namedEnv?.url + ? parseClickHouseUrl(namedEnv.url) + : undefined; + + // Priority: CLI flags > named env > env vars > CLICKHOUSE_URL > defaults const host = - config.host || env.CLICKHOUSE_HOST || parsed?.host || "localhost"; + config.host || + namedEnv?.host || namedParsed?.host || + env.CLICKHOUSE_HOST || parsed?.host || + "localhost"; const port = - config.port || env.CLICKHOUSE_PORT || parsed?.port || "8123"; + config.port || + namedEnv?.port || namedParsed?.port || + env.CLICKHOUSE_PORT || parsed?.port || + "8123"; const secure = config.secure || + namedEnv?.secure || (namedParsed?.secure ?? false) || env.CLICKHOUSE_SECURE === "true" || (parsed?.secure ?? false); const protocol = secure ? "https" : "http"; @@ -33,22 +49,28 @@ export function resolveConnectionConfig( url: `${protocol}://${host}:${port}`, username: config.user || + namedEnv?.user || env.CLICKHOUSE_USER || env.CLICKHOUSE_USERNAME || "default", password: config.password || + namedEnv?.password || namedParsed?.password || env.CLICKHOUSE_PASSWORD || parsed?.password || "", database: config.database || + namedEnv?.database || env.CLICKHOUSE_DATABASE || env.CLICKHOUSE_DB || "default", }; } -export function createClickHouseClient(config: CliConfig) { - return createClient(resolveConnectionConfig(config)); +export function createClickHouseClient( + config: CliConfig, + namedEnv?: Environment, +) { + return createClient(resolveConnectionConfig(config, process.env, namedEnv)); } diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..be33f01 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,141 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +// We test the config logic by calling the internal functions with a mocked config path. +// Since config.ts uses a hardcoded path, we test the read/write/get/set functions +// by temporarily replacing the config file. + +// Instead of mocking the module, we test the data flow functions directly +// by importing and using them with a temp directory approach. + +import { + readConfig, + writeConfig, + getEnvironment, + setEnvironment, + removeEnvironment, + setDefault, + getDefault, + getConfigPath, + type Config, +} from "./config"; + +// Since config.ts uses a fixed path, we'll test the serialization logic +// by using the actual config path but saving/restoring state. + +describe("config", () => { + let originalConfig: string | null = null; + const configPath = getConfigPath(); + + beforeEach(async () => { + const file = Bun.file(configPath); + if (await file.exists()) { + originalConfig = await file.text(); + } else { + originalConfig = null; + } + // Start with empty config + const { mkdir } = await import("node:fs/promises"); + await mkdir(join(configPath, ".."), { recursive: true }); + await Bun.write(configPath, JSON.stringify({ environments: {}, defaults: {} })); + }); + + afterEach(async () => { + if (originalConfig !== null) { + await Bun.write(configPath, originalConfig); + } else { + const { unlink } = await import("node:fs/promises"); + try { await unlink(configPath); } catch {} + } + }); + + test("readConfig returns empty config when file has empty data", async () => { + const config = await readConfig(); + expect(config.environments).toEqual({}); + expect(config.defaults).toEqual({}); + }); + + test("setEnvironment and getEnvironment round-trip", async () => { + await setEnvironment("prod", { + host: "ch.prod.com", + port: "8443", + secure: true, + user: "admin", + }); + + const env = await getEnvironment("prod"); + expect(env).toEqual({ + host: "ch.prod.com", + port: "8443", + secure: true, + user: "admin", + }); + }); + + test("setEnvironment overwrites existing environment", async () => { + await setEnvironment("prod", { host: "old.com" }); + await setEnvironment("prod", { host: "new.com" }); + + const env = await getEnvironment("prod"); + expect(env?.host).toBe("new.com"); + }); + + test("getEnvironment returns undefined for nonexistent name", async () => { + const env = await getEnvironment("nope"); + expect(env).toBeUndefined(); + }); + + test("removeEnvironment deletes the environment", async () => { + await setEnvironment("staging", { host: "staging.com" }); + const removed = await removeEnvironment("staging"); + expect(removed).toBe(true); + + const env = await getEnvironment("staging"); + expect(env).toBeUndefined(); + }); + + test("removeEnvironment returns false for nonexistent name", async () => { + const removed = await removeEnvironment("nope"); + expect(removed).toBe(false); + }); + + test("removeEnvironment cleans up defaults pointing to it", async () => { + await setEnvironment("staging", { host: "staging.com" }); + await setDefault("/some/dir", "staging"); + + await removeEnvironment("staging"); + + const def = await getDefault("/some/dir"); + expect(def).toBeUndefined(); + }); + + test("setDefault and getDefault round-trip", async () => { + await setEnvironment("prod", { host: "prod.com" }); + await setDefault("/my/project", "prod"); + + const def = await getDefault("/my/project"); + expect(def).toBe("prod"); + }); + + test("setDefault throws for nonexistent environment", async () => { + expect(setDefault("/my/project", "nope")).rejects.toThrow( + 'Environment "nope" does not exist', + ); + }); + + test("writeConfig and readConfig preserve structure", async () => { + const config: Config = { + environments: { + prod: { host: "prod.com", secure: true }, + dev: { host: "localhost" }, + }, + defaults: { "/a": "prod", "/b": "dev" }, + }; + await writeConfig(config); + + const loaded = await readConfig(); + expect(loaded).toEqual(config); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c44dbbb --- /dev/null +++ b/src/config.ts @@ -0,0 +1,95 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +const CONFIG_DIR = join(homedir(), ".config", "chcli"); +const CONFIG_PATH = join(CONFIG_DIR, "config.json"); + +export interface Environment { + host?: string; + port?: string; + user?: string; + password?: string; + database?: string; + secure?: boolean; + url?: string; +} + +export interface Config { + environments: Record; + defaults: Record; +} + +function emptyConfig(): Config { + return { environments: {}, defaults: {} }; +} + +export function getConfigPath(): string { + return CONFIG_PATH; +} + +export async function readConfig(): Promise { + const file = Bun.file(CONFIG_PATH); + if (!(await file.exists())) { + return emptyConfig(); + } + const raw = await file.json(); + return { + environments: raw.environments ?? {}, + defaults: raw.defaults ?? {}, + }; +} + +export async function writeConfig(config: Config): Promise { + const { mkdir } = await import("node:fs/promises"); + await mkdir(CONFIG_DIR, { recursive: true }); + await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); +} + +export async function getEnvironment( + name: string, +): Promise { + const config = await readConfig(); + return config.environments[name]; +} + +export async function setEnvironment( + name: string, + env: Environment, +): Promise { + const config = await readConfig(); + config.environments[name] = env; + await writeConfig(config); +} + +export async function removeEnvironment(name: string): Promise { + const config = await readConfig(); + if (!(name in config.environments)) { + return false; + } + delete config.environments[name]; + // Also remove any defaults pointing to this environment + for (const [dir, envName] of Object.entries(config.defaults)) { + if (envName === name) { + delete config.defaults[dir]; + } + } + await writeConfig(config); + return true; +} + +export async function setDefault( + directory: string, + envName: string, +): Promise { + const config = await readConfig(); + if (!(envName in config.environments)) { + throw new Error(`Environment "${envName}" does not exist`); + } + config.defaults[directory] = envName; + await writeConfig(config); +} + +export async function getDefault(directory: string): Promise { + const config = await readConfig(); + return config.defaults[directory]; +} diff --git a/src/env.test.ts b/src/env.test.ts new file mode 100644 index 0000000..e073aa6 --- /dev/null +++ b/src/env.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { runEnvCommand } from "./env"; +import { + getConfigPath, + readConfig, + setEnvironment, + setDefault, + writeConfig, +} from "./config"; +import { join } from "node:path"; + +describe("env subcommands", () => { + let originalConfig: string | null = null; + const configPath = getConfigPath(); + + beforeEach(async () => { + const file = Bun.file(configPath); + if (await file.exists()) { + originalConfig = await file.text(); + } else { + originalConfig = null; + } + const { mkdir } = await import("node:fs/promises"); + await mkdir(join(configPath, ".."), { recursive: true }); + await Bun.write( + configPath, + JSON.stringify({ environments: {}, defaults: {} }), + ); + }); + + afterEach(async () => { + if (originalConfig !== null) { + await Bun.write(configPath, originalConfig); + } else { + const { unlink } = await import("node:fs/promises"); + try { + await unlink(configPath); + } catch {} + } + }); + + test("env add creates a new environment", async () => { + await runEnvCommand([ + "add", + "prod", + "--host", + "ch.prod.com", + "--port", + "8443", + "-s", + ]); + + const config = await readConfig(); + expect(config.environments.prod).toEqual({ + host: "ch.prod.com", + port: "8443", + secure: true, + }); + }); + + test("env add merges with existing environment", async () => { + await setEnvironment("prod", { host: "old.com", user: "admin" }); + + await runEnvCommand(["add", "prod", "--host", "new.com"]); + + const config = await readConfig(); + expect(config.environments.prod.host).toBe("new.com"); + expect(config.environments.prod.user).toBe("admin"); + }); + + test("env list shows environments", async () => { + await setEnvironment("prod", { host: "ch.prod.com" }); + await setEnvironment("dev", { host: "localhost" }); + + const logs: string[] = []; + const spy = spyOn(console, "log").mockImplementation((...args: unknown[]) => + logs.push(args.join(" ")), + ); + + await runEnvCommand(["list"]); + spy.mockRestore(); + + expect(logs.some((l) => l.includes("prod"))).toBe(true); + expect(logs.some((l) => l.includes("dev"))).toBe(true); + }); + + test("env show displays environment details", async () => { + await setEnvironment("prod", { + host: "ch.prod.com", + port: "8443", + user: "admin", + password: "secret", + secure: true, + }); + + const logs: string[] = []; + const spy = spyOn(console, "log").mockImplementation((...args: unknown[]) => + logs.push(args.join(" ")), + ); + + await runEnvCommand(["show", "prod"]); + spy.mockRestore(); + + expect(logs.some((l) => l.includes("ch.prod.com"))).toBe(true); + expect(logs.some((l) => l.includes("****"))).toBe(true); + expect(logs.every((l) => !l.includes("secret"))).toBe(true); + }); + + test("env remove deletes an environment", async () => { + await setEnvironment("staging", { host: "staging.com" }); + + await runEnvCommand(["remove", "staging"]); + + const config = await readConfig(); + expect(config.environments.staging).toBeUndefined(); + }); + + test("env use sets default for current directory", async () => { + await setEnvironment("prod", { host: "prod.com" }); + + await runEnvCommand(["use", "prod"]); + + const config = await readConfig(); + expect(config.defaults[process.cwd()]).toBe("prod"); + }); +}); diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..07e8c19 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,174 @@ +import { parseArgs } from "node:util"; +import { + readConfig, + setEnvironment, + removeEnvironment, + setDefault, + getEnvironment, + type Environment, +} from "./config"; + +export async function runEnvCommand(args: readonly string[]): Promise { + const subcommand = args[0]; + const rest = args.slice(1); + + switch (subcommand) { + case "add": + return envAdd(rest); + case "list": + case "ls": + return envList(); + case "remove": + case "rm": + return envRemove(rest); + case "use": + return envUse(rest); + case "show": + return envShow(rest); + default: + printEnvHelp(); + } +} + +function printEnvHelp() { + console.log(`chcli env — Manage environments + +Usage: chcli env + +Commands: + add Add or update an environment + list, ls List all environments + remove, rm Remove an environment + use Set default environment for current directory + show Show environment details + +Add options: + --host ClickHouse host + --port HTTP port + -u, --user Username + --password Password + -d, --database Database + -s, --secure Use HTTPS + --url ClickHouse URL (sets host, port, secure, password) + +Examples: + chcli env add prod --host ch.prod.com --port 8443 --secure -u admin + chcli env use prod + chcli -e staging -q "SELECT 1"`); +} + +async function envAdd(args: readonly string[]): Promise { + const { values, positionals } = parseArgs({ + args: args as string[], + options: { + host: { type: "string" }, + port: { type: "string" }, + user: { type: "string", short: "u" }, + password: { type: "string" }, + database: { type: "string", short: "d" }, + secure: { type: "boolean", short: "s", default: false }, + url: { type: "string" }, + }, + allowPositionals: true, + strict: true, + }); + + const name = positionals[0]; + if (!name) { + console.error("Error: environment name is required\nUsage: chcli env add [options]"); + process.exit(1); + } + + const env: Environment = {}; + if (values.host) env.host = values.host; + if (values.port) env.port = values.port; + if (values.user) env.user = values.user; + if (values.password) env.password = values.password; + if (values.database) env.database = values.database; + if (values.secure) env.secure = values.secure; + if (values.url) env.url = values.url; + + const existing = await getEnvironment(name); + if (existing) { + // Merge with existing — only override fields that were explicitly passed + const merged = { ...existing, ...env }; + await setEnvironment(name, merged); + console.log(`Updated environment "${name}"`); + } else { + await setEnvironment(name, env); + console.log(`Added environment "${name}"`); + } +} + +async function envList(): Promise { + const config = await readConfig(); + const names = Object.keys(config.environments); + + if (names.length === 0) { + console.log("No environments configured. Use `chcli env add ` to add one."); + return; + } + + const cwd = process.cwd(); + const defaultEnv = config.defaults[cwd]; + + for (const name of names) { + const env = config.environments[name]; + const isDefault = name === defaultEnv; + const marker = isDefault ? " (default)" : ""; + const host = env.url ?? env.host ?? "localhost"; + console.log(` ${name}${marker} — ${host}`); + } +} + +async function envRemove(args: readonly string[]): Promise { + const name = args[0]; + if (!name) { + console.error("Error: environment name is required\nUsage: chcli env remove "); + process.exit(1); + } + + const removed = await removeEnvironment(name); + if (removed) { + console.log(`Removed environment "${name}"`); + } else { + console.error(`Environment "${name}" not found`); + process.exit(1); + } +} + +async function envUse(args: readonly string[]): Promise { + const name = args[0]; + if (!name) { + console.error("Error: environment name is required\nUsage: chcli env use "); + process.exit(1); + } + + const cwd = process.cwd(); + await setDefault(cwd, name); + console.log(`Default environment for ${cwd} set to "${name}"`); +} + +async function envShow(args: readonly string[]): Promise { + const name = args[0]; + if (!name) { + console.error("Error: environment name is required\nUsage: chcli env show "); + process.exit(1); + } + + const env = await getEnvironment(name); + if (!env) { + console.error(`Environment "${name}" not found`); + process.exit(1); + return; + } + + console.log(`Environment: ${name}`); + if (env.url) console.log(` url: ${env.url}`); + if (env.host) console.log(` host: ${env.host}`); + if (env.port) console.log(` port: ${env.port}`); + if (env.user) console.log(` user: ${env.user}`); + if (env.password) console.log(` password: ****`); + if (env.database) console.log(` database: ${env.database}`); + if (env.secure) console.log(` secure: true`); +} diff --git a/src/run.ts b/src/run.ts index 9148374..c4406a7 100644 --- a/src/run.ts +++ b/src/run.ts @@ -2,10 +2,41 @@ import { parseCliArgs, printHelp, printVersion } from "./cli"; import { createClickHouseClient } from "./client"; import { resolveQuery } from "./query"; import { resolveFormat } from "./format"; +import { runEnvCommand } from "./env"; +import { getEnvironment, getDefault } from "./config"; +import type { Environment } from "./config"; const DATA_QUERY_PATTERN = /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN|EXISTS|WITH)\b/i; +async function resolveNamedEnvironment( + envFlag: string | undefined, +): Promise { + // --env flag takes highest priority + if (envFlag) { + const env = await getEnvironment(envFlag); + if (!env) { + throw new Error(`Environment "${envFlag}" not found. Run \`chcli env list\` to see available environments.`); + } + return env; + } + + // Fall back to folder default + const defaultName = await getDefault(process.cwd()); + if (defaultName) { + return getEnvironment(defaultName); + } + + return undefined; +} + export async function run() { + // Route `chcli env ...` subcommand before strict arg parsing + const rawArgs = process.argv.slice(2); + if (rawArgs[0] === "env") { + await runEnvCommand(rawArgs.slice(1)); + return; + } + const config = parseCliArgs(); if (config.help) { @@ -18,9 +49,10 @@ export async function run() { return; } + const namedEnv = await resolveNamedEnvironment(config.env); const query = await resolveQuery(config); const format = resolveFormat(config.format); - const client = createClickHouseClient(config); + const client = createClickHouseClient(config, namedEnv); try { const start = performance.now(); From cc48253cd255686ce466ad252dfbfb8af406dd6b Mon Sep 17 00:00:00 2001 From: KeKs0r Date: Tue, 17 Mar 2026 18:45:24 +0800 Subject: [PATCH 2/4] Fix TypeScript errors for possibly-undefined environment values Co-Authored-By: Claude Opus 4.6 --- src/env.test.ts | 4 ++-- src/env.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/env.test.ts b/src/env.test.ts index e073aa6..fb27de9 100644 --- a/src/env.test.ts +++ b/src/env.test.ts @@ -64,8 +64,8 @@ describe("env subcommands", () => { await runEnvCommand(["add", "prod", "--host", "new.com"]); const config = await readConfig(); - expect(config.environments.prod.host).toBe("new.com"); - expect(config.environments.prod.user).toBe("admin"); + expect(config.environments.prod!.host).toBe("new.com"); + expect(config.environments.prod!.user).toBe("admin"); }); test("env list shows environments", async () => { diff --git a/src/env.ts b/src/env.ts index 07e8c19..8a2f6ac 100644 --- a/src/env.ts +++ b/src/env.ts @@ -116,7 +116,7 @@ async function envList(): Promise { const env = config.environments[name]; const isDefault = name === defaultEnv; const marker = isDefault ? " (default)" : ""; - const host = env.url ?? env.host ?? "localhost"; + const host = env?.url ?? env?.host ?? "localhost"; console.log(` ${name}${marker} — ${host}`); } } From 670d5370d56f9704bfb3994fcc478a822232927f Mon Sep 17 00:00:00 2001 From: KeKs0r Date: Tue, 17 Mar 2026 19:36:09 +0800 Subject: [PATCH 3/4] Fix e2e test: avoid system.numbers which requires elevated privileges Use UNION ALL query instead of system.numbers table to avoid permission issues in CI environments with restricted ClickHouse users. Co-Authored-By: Claude Opus 4.6 --- e2e/cli.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/cli.test.ts b/e2e/cli.test.ts index 4e8e98c..3d8e4fc 100644 --- a/e2e/cli.test.ts +++ b/e2e/cli.test.ts @@ -60,7 +60,7 @@ describe("e2e", () => { test("multi-row query with markdown format", async () => { const result = - await $`bun ${CLI_PATH} -q "SELECT number AS n FROM system.numbers LIMIT 3" -F markdown`.text(); + await $`bun ${CLI_PATH} -q "SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2" -F markdown`.text(); expect(result).toContain("| n |"); expect(result).toContain("| 0 |"); expect(result).toContain("| 1 |"); From 03eaafe99c44a0c84232484be5a0a6e7f9fd835e Mon Sep 17 00:00:00 2001 From: KeKs0r Date: Tue, 17 Mar 2026 19:40:38 +0800 Subject: [PATCH 4/4] Update skill docs with named environments feature Document the env subcommand, --env/-e flag, folder defaults, and updated resolution order in both SKILL.md and connection reference. Co-Authored-By: Claude Opus 4.6 --- skills/clickhouse-query/SKILL.md | 35 ++++++++++++-- .../clickhouse-query/references/connection.md | 48 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/skills/clickhouse-query/SKILL.md b/skills/clickhouse-query/SKILL.md index 092d2ac..c73a7a5 100644 --- a/skills/clickhouse-query/SKILL.md +++ b/skills/clickhouse-query/SKILL.md @@ -30,7 +30,35 @@ chcli -q "SELECT 1" ## Connection -Set connection details via environment variables (preferred for agent use) or CLI flags. +Set connection details via named environments (recommended), environment variables, or CLI flags. + +### Named Environments + +Named environments store connection profiles in `~/.config/chcli/config.json`. This is the recommended approach when working with multiple ClickHouse instances. + +```bash +# Save a named environment +chcli env add prod --host ch.prod.com --port 8443 --secure -u admin --password secret + +# Use it for a query +chcli -e prod -q "SELECT count() FROM events" + +# Or set it as the default for the current directory +chcli env use prod +chcli -q "SELECT count() FROM events" # uses prod automatically +``` + +Manage environments with `chcli env`: + +| Subcommand | Description | +|------------|-------------| +| `env add ` | Add or update an environment (merges with existing) | +| `env list` / `env ls` | List all environments | +| `env show ` | Show environment details (passwords masked) | +| `env remove ` / `env rm` | Remove an environment | +| `env use ` | Set default environment for the current directory | + +### Environment Variables | Flag | Env Var | Alt Env Var | Default | |------|---------|-------------|---------| @@ -47,10 +75,10 @@ Set connection details via environment variables (preferred for agent use) or CL ### Resolution Order ``` -CLI flag > Individual env var > CLICKHOUSE_URL (parsed) > Default value +CLI flag > Named environment (--env or folder default) > Individual env var > CLICKHOUSE_URL (parsed) > Default value ``` -For agent workflows, prefer setting env vars in a `.env` file (Bun loads `.env` automatically) or using a secrets manager like Doppler so every invocation uses the same connection without repeating flags. +For agent workflows, prefer setting env vars in a `.env` file (Bun loads `.env` automatically), using named environments, or a secrets manager like Doppler so every invocation uses the same connection without repeating flags. See `references/connection.md` for detailed connection examples. @@ -141,6 +169,7 @@ bunx @obsessiondb/chcli -q "SELECT * FROM events LIMIT 1000" -F json > export.js | Flag | Description | |------|-------------| +| `-e, --env ` | Use a named environment (overrides folder default) | | `-t, --time` | Print execution time to stderr | | `-v, --verbose` | Print query metadata (format, elapsed time) to stderr | | `--help` | Show help text | diff --git a/skills/clickhouse-query/references/connection.md b/skills/clickhouse-query/references/connection.md index b034773..bf682fd 100644 --- a/skills/clickhouse-query/references/connection.md +++ b/skills/clickhouse-query/references/connection.md @@ -5,9 +5,11 @@ chcli connects to ClickHouse over HTTP(S). Connection details can be set via env ## Resolution Order ``` -CLI flag > Individual env var > CLICKHOUSE_URL (parsed) > Default value +CLI flag > Named environment (--env or folder default) > Individual env var > CLICKHOUSE_URL (parsed) > Default value ``` +When a named environment is active (via `--env`/`-e` flag or a folder default set with `chcli env use`), its values take precedence over environment variables but are overridden by explicit CLI flags. + When `CLICKHOUSE_URL` is set (e.g. `https://host:8443`), it is parsed into host, port, secure, and password. These parsed values are used as fallbacks only when the corresponding individual env var is not set. ## Configuration Options @@ -20,6 +22,7 @@ When `CLICKHOUSE_URL` is set (e.g. `https://host:8443`), it is parsed into host, | `--password ` | `CLICKHOUSE_PASSWORD` | | *(empty)* | Authentication password | | `-d, --database ` | `CLICKHOUSE_DATABASE` | `CLICKHOUSE_DB` | `default` | Default database for queries | | `-s, --secure` | `CLICKHOUSE_SECURE` | | `false` | Use HTTPS instead of HTTP | +| `-e, --env ` | *(none)* | | *(none)* | Use a named environment | | *(none)* | `CLICKHOUSE_URL` | | *(none)* | Full connection URL (parsed into host, port, secure, password) | ## Connection URL @@ -32,6 +35,49 @@ chcli constructs the connection URL as: Where `protocol` is `https` if `--secure` is set or `CLICKHOUSE_SECURE=true`, otherwise `http`. +## Named Environments + +Named environments store connection profiles locally in `~/.config/chcli/config.json`. Use them to switch between multiple ClickHouse instances without changing env vars. + +### Managing Environments + +```bash +# Add a new environment +chcli env add prod --host ch.prod.com --port 8443 --secure -u admin --password secret -d analytics + +# Add another +chcli env add staging --host ch.staging.com --port 8443 --secure -u dev + +# List all environments +chcli env list + +# Show details (passwords are masked) +chcli env show prod + +# Update an existing environment (merges — only overwrites fields you pass) +chcli env add prod --password new-secret + +# Remove an environment +chcli env remove staging +``` + +### Using Named Environments + +```bash +# Specify per-query with --env / -e +chcli -e prod -q "SELECT count() FROM events" + +# Set a default for the current directory +chcli env use prod +chcli -q "SELECT count() FROM events" # uses prod automatically + +# --env flag overrides the folder default +chcli -e staging -q "SELECT count() FROM events" + +# CLI flags still override named environment values +chcli -e prod -d other_db -q "SHOW TABLES" +``` + ## Examples ### Local Development (defaults)