Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 54 additions & 13 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -7376,6 +7376,12 @@ static const char *detect_arch(void) {

/* ── Agent config install/refresh (shared by install + update) ── */

/* Set by `install --clients=...` after validation and consumed by both the
* legacy detector and the registry-backed client path (#1558, #1798). */
static const char *g_client_selection = NULL;
static bool cli_clients_apply_selection(const char *spec, cbm_detected_agents_t *detected);
static bool cli_clients_selects_registry_client(const char *spec, cbm_agent_client_id_t client_id);
static void cli_clients_print_list(FILE *out);
static void print_detected_registry_agents(const char *home, bool *any);

/* Print detected agent names on a single line. */
Expand Down Expand Up @@ -8169,7 +8175,10 @@ static void print_detected_registry_agents(const char *home, bool *any) {
cbm_init_agent_registry_context(home, &registry);
for (size_t index = 0U; index < cbm_agent_client_count(); index++) {
const cbm_agent_client_profile_t *profile = cbm_agent_client_at(index);
if (profile && cbm_agent_client_detect(profile->id, &registry.options)) {
if (profile &&
(!g_client_selection ||
cli_clients_selects_registry_client(g_client_selection, profile->id)) &&
cbm_agent_client_detect(profile->id, &registry.options)) {
printf(" %s", profile->display_name);
*any = true;
}
Expand Down Expand Up @@ -8525,7 +8534,10 @@ static void install_agent_client_registry(const char *home, const char *binary_p
cbm_init_agent_registry_context(home, &registry);
for (size_t index = 0U; index < cbm_agent_client_count(); index++) {
const cbm_agent_client_profile_t *profile = cbm_agent_client_at(index);
if (!profile || !cbm_agent_client_detect(profile->id, &registry.options)) {
if (!profile ||
(g_client_selection &&
!cli_clients_selects_registry_client(g_client_selection, profile->id)) ||
!cbm_agent_client_detect(profile->id, &registry.options)) {
continue;
}
if (!g_install_plan) {
Expand Down Expand Up @@ -9365,13 +9377,6 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents
}
}

/* #1558: set by `install --clients=...` after validation, consumed at the one
* place detection happens. Validation runs in cbm_cmd_install so an unknown
* token fails before anything is written, not midway through configuring. */
static const char *g_client_selection = NULL;
static bool cli_clients_apply_selection(const char *spec, cbm_detected_agents_t *detected);
static void cli_clients_print_list(FILE *out);

int cbm_install_agent_configs(const char *home, const char *binary_path, bool force, bool dry_run) {
g_agent_install_errors = 0;
cbm_detected_agents_t agents = cbm_detect_agents(home);
Expand Down Expand Up @@ -9499,9 +9504,9 @@ int cbm_install_handle_existing_indexes(const char *home, bool reset, bool dry_r
* Codex had to revert the OpenCode and Cursor integrations by hand, and the
* next install silently recreated them. `--clients=claude,codex` restricts it.
*
* The table is the flag's vocabulary AND its documentation: 26 clients ship
* here, with tokens nobody would guess (factory-droid, mistral-vibe,
* copilot-cli), so `--clients=help` prints every token. A selector whose
* The tables are the flag's vocabulary AND its documentation, with tokens
* nobody would guess (factory-droid, mistral-vibe, copilot-cli), so
* `--clients=help` prints every token. A selector whose
* accepted values can only be learned from the source is not a usable
* selector, and an unrecognised token fails loudly with the list rather than
* silently configuring nothing. */
Expand Down Expand Up @@ -9549,10 +9554,40 @@ static void cli_clients_print_list(FILE *out) {
for (size_t i = 0; i < CLI_CLIENT_COUNT; i++) {
(void)fprintf(out, " %-16s %s\n", CLI_CLIENTS[i].token, CLI_CLIENTS[i].display);
}
for (size_t i = 0; i < cbm_agent_client_count(); i++) {
const cbm_agent_client_profile_t *profile = cbm_agent_client_at(i);
if (profile) {
(void)fprintf(out, " %-16s %s\n", profile->stable_id, profile->display_name);
}
}
(void)fprintf(out, "\nExample: --clients=claude,codex\n"
"Omit --clients to configure every detected client.\n");
}

static bool cli_clients_spec_contains(const char *spec, const char *wanted_token) {
char buf[CLI_BUF_1K];
snprintf(buf, sizeof(buf), "%s", spec ? spec : "");
char *save = NULL;
for (char *tok = strtok_r(buf, ",", &save); tok; tok = strtok_r(NULL, ",", &save)) {
while (*tok == ' ') {
tok++;
}
size_t len = strlen(tok);
while (len > 0 && tok[len - 1] == ' ') {
tok[--len] = '\0';
}
if (strcmp(tok, wanted_token) == 0) {
return true;
}
}
return false;
}

static bool cli_clients_selects_registry_client(const char *spec, cbm_agent_client_id_t client_id) {
const cbm_agent_client_profile_t *profile = cbm_agent_client_by_id(client_id);
return profile && cli_clients_spec_contains(spec, profile->stable_id);
}

/* Restrict `detected` to the comma-separated token list. Returns false (after
* printing the vocabulary) when a token is unknown, so a typo can never be
* mistaken for "that client was not installed". */
Expand Down Expand Up @@ -9581,6 +9616,9 @@ static bool cli_clients_apply_selection(const char *spec, cbm_detected_agents_t
break;
}
}
if (!matched) {
matched = cbm_agent_client_by_stable_id(tok) != NULL;
}
if (!matched) {
(void)fprintf(stderr, "error: unknown client: %s\n\n", tok);
cli_clients_print_list(stderr);
Expand All @@ -9606,6 +9644,9 @@ size_t cbm_cli_clients_count_for_testing(void) {
const char *cbm_cli_clients_token_for_testing(size_t index) {
return index < CLI_CLIENT_COUNT ? CLI_CLIENTS[index].token : NULL;
}
void cbm_cli_set_client_selection_for_testing(const char *spec) {
g_client_selection = spec;
}
#endif

/* ── Subcommand: install ──────────────────────────────────────── */
Expand Down Expand Up @@ -10018,7 +10059,7 @@ int cbm_cmd_install(int argc, char **argv) {
}
} else if (strcmp(argv[i], "--clients") == 0) {
/* Bare `--clients` (and `--clients=help`/`list`) print the
* vocabulary. 26 clients ship with tokens nobody would guess. */
* vocabulary, including tokens users would not readily guess. */
cli_clients_print_list(stdout);
return 0;
} else if (strcmp(argv[i], "--skip-binary") == 0) {
Expand Down
1 change: 1 addition & 0 deletions src/cli/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ int cbm_install_agent_configs(const char *home, const char *binary_path, bool fo
bool cbm_cli_clients_apply_selection_for_testing(const char *spec, cbm_detected_agents_t *detected);
size_t cbm_cli_clients_count_for_testing(void);
const char *cbm_cli_clients_token_for_testing(size_t index);
void cbm_cli_set_client_selection_for_testing(const char *spec);
#endif

#ifdef CBM_CLI_ENABLE_TEST_API
Expand Down
37 changes: 36 additions & 1 deletion tests/test_cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -12794,7 +12794,7 @@ TEST(cli_external_manager_detection_needs_positive_evidence_issue1566) {
* Claude and Codex had to revert OpenCode and Cursor by hand — and the next
* install silently recreated them. The selector restricts it.
*
* The vocabulary is the part that makes it usable: 26 clients ship, with tokens
* The vocabulary is the part that makes it usable: clients ship with tokens
* nobody would guess (factory-droid, mistral-vibe, copilot-cli). A selector
* whose accepted values can only be learned by reading our source is not a
* usable selector, so this pins that every client is listed and that an unknown
Expand Down Expand Up @@ -12823,6 +12823,12 @@ TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558) {
cbm_detected_agents_t typo = all;
ASSERT_FALSE(cbm_cli_clients_apply_selection_for_testing("claude,codx", &typo));

/* Registry-backed clients share the public selector vocabulary. */
cbm_detected_agents_t registry = all;
ASSERT_TRUE(cbm_cli_clients_apply_selection_for_testing("qoder", &registry));
ASSERT_FALSE(registry.claude_code);
ASSERT_FALSE(registry.cursor);

/* Every token in the table must resolve — a client added to detection but
* forgotten here is invisible to the selector. */
for (size_t i = 0; i < cbm_cli_clients_count_for_testing(); i++) {
Expand All @@ -12834,6 +12840,34 @@ TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558) {
PASS();
}

TEST(cli_clients_selector_filters_registry_installs_issue1798) {
char *tmpdir = th_mktempdir("cbm_cli_clients_registry");
ASSERT_NOT_NULL(tmpdir);

char qoder_dir[512];
char settings_path[512];
ASSERT(snprintf(qoder_dir, sizeof(qoder_dir), "%s/.qoder", tmpdir) > 0);
ASSERT(snprintf(settings_path, sizeof(settings_path), "%s/settings.json", qoder_dir) > 0);
ASSERT_EQ(test_mkdirp(qoder_dir), 0);

cbm_cli_set_client_selection_for_testing("cursor");
int excluded_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
struct stat settings_state;
bool excluded = stat(settings_path, &settings_state) != 0;

cbm_cli_set_client_selection_for_testing("qoder");
int included_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
bool included = stat(settings_path, &settings_state) == 0;
cbm_cli_set_client_selection_for_testing(NULL);

test_rmdir_r(tmpdir);
ASSERT_EQ(excluded_rc, 0);
ASSERT_TRUE(excluded);
ASSERT_EQ(included_rc, 0);
ASSERT_TRUE(included);
PASS();
}

TEST(cli_update_accepts_retired_variant_flags_issue1544) {
char *ui_argv[] = {"--dry-run", "--ui", "--yes"};
char *standard_argv[] = {"--dry-run", "--standard", "--yes"};
Expand Down Expand Up @@ -12981,6 +13015,7 @@ SUITE(cli) {
RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554);
RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566);
RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558);
RUN_TEST(cli_clients_selector_filters_registry_installs_issue1798);
RUN_TEST(cli_update_accepts_retired_variant_flags_issue1544);
RUN_TEST(cli_update_agent_configs_finish_before_guard_release);
RUN_TEST(cli_uninstall_quiesces_active_cohort_before_removing_binary_and_index);
Expand Down
Loading