fix(cli): make --registry parameter work for all commands

Fixed the issue where the --registry command-line parameter was defined
but not actually used by any commands.

Changes:
- Added loadConfigFromProgram() helper in config.ts to read registry from program.opts()
- Updated all 21 command files to use loadConfigFromProgram() instead of loadConfig()
- Ensured correct priority: CLI args > env vars > config file > defaults

Priority order:
1. --registry <url> (command-line flag) - highest priority
2. SKILLHUB_REGISTRY (environment variable)
3. ~/.skillhub/config.json (config file)
4. http://localhost:8080 (default value)

This allows users to temporarily override the registry without modifying
environment variables or config files, providing better flexibility for:
- Testing against different registries
- Multi-project/multi-environment setups
- CI/CD automation scripts

Updated commands:
- install, download, update, check, sync, uninstall
- explore, search
- publish, delete, archive, versions
- inspect, resolve, rating, rate, star, report, reviews
- whoami, me, namespaces, notifications
- transfer, hide, unhide

Fixes issue where `npx motovis-skillhub install <skill> --registry <url>`
would ignore the --registry parameter and use env vars or defaults instead.
This commit is contained in:
chenbaowang 2026-04-20 21:10:07 +08:00
parent d6b9f026b4
commit 2543cd52e7
22 changed files with 94 additions and 62 deletions

View file

@ -1,7 +1,7 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { success, error } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
@ -27,7 +27,7 @@ export function registerArchive(program: Command) {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
await client.post(`/api/v1/skills/${namespace}/${skillSlug}/archive`);
success(`Archived ${skillSlug}`);

View file

@ -1,7 +1,7 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { success, error } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
@ -28,7 +28,7 @@ export function registerDelete(program: Command) {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`);
success(`Deleted ${skillSlug} from ${namespace}`);

View file

@ -4,7 +4,7 @@ import { resolve } from "node:path";
import { finished } from "node:stream/promises";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { success, error } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
@ -19,7 +19,7 @@ export function registerDownload(program: Command) {
.option("--output <dir>", "Output directory")
.action(async (slug: string, opts: Record<string, string>) => {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const config = loadConfig();
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });

View file

@ -1,6 +1,6 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { ApiRoutes } from "../schema/routes.js";
import { info, dim } from "../utils/logger.js";
@ -222,7 +222,7 @@ export function registerExplore(program: Command) {
.option("-n, --limit <n>", "Max results", "20")
.option("-s, --sort <sort>", "Sort by: hot, newest, downloads (default: interactive mode)")
.action(async (query: string | undefined, opts: { limit: string; sort?: string }) => {
const config = loadConfig();
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
const sortMap: Record<string, string> = { hot: "rating", newest: "newest", downloads: "downloads" };

View file

@ -1,7 +1,7 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { success, error } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
@ -27,7 +27,7 @@ export function registerHide(program: Command) {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(
@ -67,7 +67,7 @@ export function registerHide(program: Command) {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(

View file

@ -1,7 +1,7 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { parseSkillName } from "../core/skill-name.js";
import { info, dim, error } from "../utils/logger.js";
@ -70,7 +70,7 @@ export function registerInspect(program: Command) {
.description("View skill metadata without installing")
.option("--namespace <ns>", "Search in specific namespace (searches all if not specified)")
.action(async (slug: string, opts: { namespace?: string }) => {
const config = loadConfig();
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });

View file

@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createWriteStream, createReadStream, existsSync, mkdirSync } from "node:fs";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { loadConfigFromProgram } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { discoverSkills } from "../core/skill-discovery.js";
import { installSkill } from "../core/installer.js";
@ -227,9 +227,9 @@ export function registerInstall(program: Command) {
try {
if (effectiveSource === "registry") {
await installFromRegistry(source, opts, spinner);
await installFromRegistry(source, opts, spinner, program);
} else {
await installFromGit(source, installSource, effectiveSource, opts, spinner);
await installFromGit(source, installSource, effectiveSource, opts, spinner, program);
}
} catch (e: any) {
spinner.fail(e.message);
@ -238,7 +238,12 @@ export function registerInstall(program: Command) {
});
}
async function installFromRegistry(slug: string, opts: Record<string, string | string[] | boolean>, spinner: any) {
async function installFromRegistry(
slug: string,
opts: Record<string, string | string[] | boolean>,
spinner: any,
program: Command
) {
let ns = "global";
let actualSlug = slug;
let userSpecifiedNamespace = false;
@ -252,7 +257,7 @@ async function installFromRegistry(slug: string, opts: Record<string, string | s
}
}
const config = loadConfig();
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
@ -581,7 +586,14 @@ async function installFromRegistry(slug: string, opts: Record<string, string | s
await rm(tmpDir, { recursive: true, force: true });
}
async function installFromGit(skillName: string, source: string, sourceType: SourceType, opts: Record<string, string | string[] | boolean>, spinner: any) {
async function installFromGit(
skillName: string,
source: string,
sourceType: SourceType,
opts: Record<string, string | string[] | boolean>,
spinner: any,
program: Command
) {
let skillsDir: string;
const parsed = parseSource(source);

View file

@ -1,6 +1,6 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { requireToken } from "../core/auth-token.js";
import { error, info, dim } from "../utils/logger.js";
@ -33,7 +33,7 @@ export function registerMe(program: Command) {
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const resp = await client.get<MeSkillsResponse>("/api/v1/me/skills");
const skills = resp.items || [];
@ -63,7 +63,7 @@ export function registerMe(program: Command) {
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const resp = await client.get<MeSkillsResponse>("/api/v1/me/stars");
const skills = resp.items || [];

View file

@ -2,7 +2,7 @@ import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, NamespaceResponse } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { error } from "../utils/logger.js";
export function registerNamespaces(program: Command) {
@ -12,7 +12,7 @@ export function registerNamespaces(program: Command) {
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const namespaces = await client.get<NamespaceResponse[]>(ApiRoutes.meNamespaces);
const isJson = program.opts().json;

View file

@ -1,6 +1,6 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { requireToken } from "../core/auth-token.js";
import { success, error, info, dim } from "../utils/logger.js";
@ -26,7 +26,7 @@ export function registerNotifications(program: Command) {
.action(async (opts: { unread?: boolean }) => {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const notifs = await client.get<Notification[]>("/api/v1/notifications");
const filtered = opts.unread ? notifs.filter((n) => !n.read) : notifs;
@ -50,7 +50,7 @@ export function registerNotifications(program: Command) {
.action(async (id: string) => {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
await client.put(`/api/v1/notifications/${id}/read`);
success(`Marked notification ${id} as read`);
@ -66,7 +66,7 @@ export function registerNotifications(program: Command) {
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
await client.put("/api/v1/notifications/read-all");
success("All notifications marked as read");

View file

@ -5,7 +5,7 @@ import { FormData } from "undici";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, PublishResponse } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { success, error, info } from "../utils/logger.js";
import ora from "ora";
import semver from "semver";
@ -49,7 +49,7 @@ export function registerPublish(program: Command) {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const spinner = ora(`Publishing ${slug}@${version} to ${namespace}`).start();

View file

@ -1,6 +1,6 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { requireToken } from "../core/auth-token.js";
import { success, error, info, dim } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
@ -13,7 +13,7 @@ export function registerRating(program: Command) {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(
@ -51,7 +51,7 @@ export function registerRate(program: Command) {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const detail = await client.get<{ id: number }>(

View file

@ -2,7 +2,7 @@ import { Command } from "commander";
import { createInterface } from "node:readline";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { success, error } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
@ -15,7 +15,7 @@ export function registerReport(program: Command) {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
let reason = opts.reason;

View file

@ -1,6 +1,6 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { success, error, info, dim } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
@ -51,7 +51,7 @@ export function registerResolve(program: Command) {
.action(async (slug: string, opts: Record<string, string>) => {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const config = loadConfig();
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });

View file

@ -1,7 +1,7 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { success, error, info, dim } from "../utils/logger.js";
export interface ReviewSubmission {
@ -24,7 +24,7 @@ export function registerReviews(program: Command) {
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const submissions = await client.get<ReviewSubmission[]>("/api/v1/reviews/my-submissions");
if (!submissions || submissions.length === 0) {

View file

@ -1,7 +1,7 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, SearchResponse } from "../schema/routes.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { readToken } from "../core/auth-token.js";
import { error, dim } from "../utils/logger.js";
@ -12,7 +12,7 @@ export function registerSearch(program: Command) {
.option("-n, --limit <n>", "Max results", "20")
.option("--namespace <ns>", "Filter by namespace")
.action(async (query: string[], opts: { limit: string; namespace?: string }) => {
const config = loadConfig();
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });

View file

@ -2,7 +2,7 @@ import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { success, error } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
@ -15,7 +15,7 @@ export function registerStar(program: Command) {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const detailPath = ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", skillSlug);

View file

@ -5,7 +5,7 @@ import { FormData } from "undici";
import { existsSync } from "node:fs";
import { discoverSkills } from "../core/skill-discovery.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { info, dim, success, error } from "../utils/logger.js";
@ -36,7 +36,7 @@ export function registerSync(program: Command) {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
info(`Scanning ${scanPath} for skills...`);

View file

@ -2,7 +2,7 @@ import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { ApiRoutes } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { success, error } from "../utils/logger.js";
export function registerTransfer(program: Command) {
@ -26,7 +26,7 @@ export function registerTransfer(program: Command) {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
await client.post(ApiRoutes.namespaceTransferOwnership.replace("{namespace}", namespace), { body: JSON.stringify({ newOwnerId }) });
success(`Ownership of ${namespace} transferred to ${newOwnerId}`);

View file

@ -1,7 +1,7 @@
import { Command } from "commander";
import { ApiClient } from "../core/api-client.js";
import { readToken } from "../core/auth-token.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
import { error, info, dim, success } from "../utils/logger.js";
import { parseSkillName } from "../core/skill-name.js";
import { searchSkills, runInteractiveSearch } from "../core/interactive-search.js";
@ -37,7 +37,7 @@ export function registerVersions(program: Command) {
.action(async (slug: string) => {
try {
const { namespace, slug: skillSlug } = parseSkillName(slug);
const config = loadConfig();
const config = loadConfigFromProgram(program);
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });

View file

@ -3,7 +3,7 @@ import { ApiClient } from "../core/api-client.js";
import { ApiRoutes, WhoamiResponse } from "../schema/routes.js";
import { requireToken } from "../core/auth-token.js";
import { success, error } from "../utils/logger.js";
import { loadConfig } from "../core/config.js";
import { loadConfig, loadConfigFromProgram } from "../core/config.js";
export function registerWhoami(program: Command) {
program
@ -12,7 +12,7 @@ export function registerWhoami(program: Command) {
.action(async () => {
try {
const token = await requireToken();
const config = loadConfig();
const config = loadConfigFromProgram(program);
const client = new ApiClient({ baseUrl: config.registry, token });
const resp = await client.get<WhoamiResponse>(ApiRoutes.whoami);
const isJson = program.opts().json;

View file

@ -1,6 +1,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Command } from "commander";
const CONFIG_DIR = join(homedir(), ".skillhub");
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
@ -14,24 +15,43 @@ const DEFAULT_CONFIG: CliConfig = {
registry: "http://localhost:8080",
};
export function loadConfig(): CliConfig {
export function loadConfig(overrides?: Partial<CliConfig>): CliConfig {
// Priority: overrides > env > config file > defaults
const envRegistry = process.env.SKILLHUB_REGISTRY;
if (envRegistry) {
if (!existsSync(CONFIG_FILE)) return { registry: envRegistry };
const baseConfig: CliConfig = envRegistry
? { registry: envRegistry }
: { ...DEFAULT_CONFIG };
if (existsSync(CONFIG_FILE)) {
try {
const raw = readFileSync(CONFIG_FILE, "utf-8");
return { registry: envRegistry, ...JSON.parse(raw) };
Object.assign(baseConfig, JSON.parse(raw));
} catch {
return { registry: envRegistry };
// Use base config if file is invalid
}
}
if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
try {
const raw = readFileSync(CONFIG_FILE, "utf-8");
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_CONFIG };
// Apply overrides (e.g., from command-line options)
if (overrides) {
Object.assign(baseConfig, overrides);
}
return baseConfig;
}
/**
* Helper function to load config with command-line options from Commander.js program
* Use this in command actions to get config that respects --registry flag
*/
export function loadConfigFromProgram(program: Command): CliConfig {
const opts = program.opts();
const overrides: Partial<CliConfig> = {};
if (opts.registry) {
overrides.registry = opts.registry as string;
}
return loadConfig(overrides);
}
export function saveConfig(config: Partial<CliConfig>): void {