Scan profiles from ~/.coi/profiles/ alongside ~/.config/coi/profiles/#278
Scan profiles from ~/.coi/profiles/ alongside ~/.config/coi/profiles/#278
Conversation
Profiles can now live under any of `/etc/coi/profiles/`, `~/.config/coi/profiles/`, `~/.coi/profiles/`, or `./.coi/profiles/`. All locations are merged into a single namespace so users can keep profiles alongside their XDG config or alongside runtime data (sessions/storage/logs under ~/.coi/). If the same profile name is defined in more than one location, COI refuses to start and points to both files, asking the user to rename one. This keeps it unambiguous which profile is actually being applied and avoids silent override surprises across scan locations. Adds a new `GetProfileParentDirs()` helper that returns all scan locations, and refactors `Load()` to iterate it independently from config file loading. Covered by Go unit tests (home-only loading, cross-location merge, home-vs-home duplicate error, project-vs-home duplicate error) and Python integration tests exercising `coi profile list` / `coi profile info` with a fake HOME across XDG, ~/.coi, and project .coi locations.
Simplify config hierarchy to only ~/.coi/ (user) and ./.coi/ (project). The system-wide /etc/coi/ and XDG ~/.config/coi/ locations are no longer scanned for config files or profiles. This keeps all COI data (config, profiles, sessions, storage, logs) co-located under ~/.coi/. Updates GetConfigPaths(), GetProfileParentDirs(), all tests, docs, CI workflow, and error messages to reflect the new two-location model.
There was a problem hiding this comment.
Pull request overview
This PR expands COI’s profile discovery to also scan ~/.coi/profiles/ (alongside existing system/user/project locations), merging all discovered profiles into a single namespace and failing fast on duplicate profile names across locations.
Changes:
- Add
GetProfileParentDirs()to centralize profile scan roots (system, user config, user~/.coi, project, and$COI_CONFIGparent). - Update
Load()to scan profiles fromGetProfileParentDirs()and reject duplicate profile names across scan locations. - Update documentation and add/extend Go + Python tests to cover new scan behavior and duplicate detection.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
internal/config/config.go |
Adds GetProfileParentDirs() helper defining profile scan roots. |
internal/config/loader.go |
Scans profiles via GetProfileParentDirs() and errors on duplicate profile names across locations; updates embedded example docs. |
internal/config/config_test.go |
Adds unit tests for GetProfileParentDirs() behavior. |
internal/config/loader_test.go |
Adds unit tests ensuring Load() scans/merges across locations and rejects duplicates. |
tests/config/test_profile_home_coi_dir.py |
Adds integration tests verifying CLI behavior for ~/.coi/profiles/, merging, and duplicate errors. |
README.md |
Documents the expanded set of profile locations and duplicate-name behavior. |
CHANGELOG.md |
Notes the improvement to scan ~/.coi/profiles/ and describes merged-namespace behavior. |
Comments suppressed due to low confidence (1)
CHANGELOG.md:71
- Changelog entry says “Profiles from all scan locations” and then lists 4 directories, but the code also scans dirname($COI_CONFIG) when COI_CONFIG is set. Either include that in the list or adjust the wording to avoid implying the list is exhaustive.
- [Improvement] **Standardize CLI `Use` strings** — All positional argument placeholders now follow a consistent `<required>` / `[optional]` lowercase-kebab-case convention. Affected commands: `tmux send`, `tmux capture`, `info`, `run`.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| if len(dirs) != 2 { | ||
| t.Fatalf("Expected exactly 2 profile parent dirs (user + project), got %d: %v", len(dirs), dirs) | ||
| } | ||
|
|
||
| // Check that user config path contains .config | ||
| homeDir, _ := os.UserHomeDir() | ||
| expectedUserPath := filepath.Join(homeDir, ".config/coi/config.toml") | ||
| if paths[1] != expectedUserPath { | ||
| t.Errorf("User config path: expected %q, got %q", expectedUserPath, paths[1]) | ||
| workDir, _ := os.Getwd() | ||
|
|
||
| expected := []string{ |
There was a problem hiding this comment.
TestGetProfileParentDirs builds expected paths using os.UserHomeDir()/os.Getwd() while ignoring their errors, but GetProfileParentDirs() has explicit fallbacks (/tmp and .) when those calls fail. If either call errors in the test environment, the expected values will diverge and the test will fail. Mirror the same fallback logic in the test (or assert membership/prefixes rather than exact equality) to make this test robust.
README.md
Outdated
| ``` | ||
|
|
||
| Profile directories are scanned at all config levels (`/etc/coi/profiles/`, `~/.config/coi/profiles/`, `.coi/profiles/`). See the [Profiles wiki page](https://github.com/mensfeld/code-on-incus/wiki/Profiles) for complete documentation. | ||
| Profile directories are scanned under `~/.coi/profiles/` (user) and `./.coi/profiles/` (project). Profiles from both locations are merged into a single namespace — if the same profile name is defined in both, COI refuses to start and asks you to rename one so it's always unambiguous which profile is being applied. See the [Profiles wiki page](https://github.com/mensfeld/code-on-incus/wiki/Profiles) for complete documentation. |
There was a problem hiding this comment.
This sentence claims profiles are scanned from “all known locations” but the implementation also scans dirname($COI_CONFIG) via GetProfileParentDirs(). Either mention the COI_CONFIG parent directory here or soften the wording so the README list remains accurate.
| Profile directories are scanned under `~/.coi/profiles/` (user) and `./.coi/profiles/` (project). Profiles from both locations are merged into a single namespace — if the same profile name is defined in both, COI refuses to start and asks you to rename one so it's always unambiguous which profile is being applied. See the [Profiles wiki page](https://github.com/mensfeld/code-on-incus/wiki/Profiles) for complete documentation. | |
| Profile directories are scanned from `~/.coi/profiles/` (user), `./.coi/profiles/` (project), and profile locations derived from the parent directory of the active `COI_CONFIG`. Profiles from all discovered locations are merged into a single namespace — if the same profile name is defined in more than one location, COI refuses to start and asks you to rename one so it's always unambiguous which profile is being applied. See the [Profiles wiki page](https://github.com/mensfeld/code-on-incus/wiki/Profiles) for complete documentation. |
internal/config/loader.go
Outdated
| # Profile directory scan locations: | ||
| # 1. ~/.coi/profiles/NAME/config.toml | ||
| # 2. .coi/profiles/NAME/config.toml | ||
| # | ||
| # Profiles from both locations are merged into a single namespace — if the | ||
| # same name is defined in both, COI will refuse to start and ask you to | ||
| # rename one. | ||
| # |
There was a problem hiding this comment.
The embedded example docs list 4 profile scan locations, but GetProfileParentDirs() also scans dirname($COI_CONFIG) when COI_CONFIG is set. Please update this list (or clarify it’s the default set excluding COI_CONFIG) so the embedded documentation matches runtime behavior.
| # Profile directory scan locations: | |
| # 1. ~/.coi/profiles/NAME/config.toml | |
| # 2. .coi/profiles/NAME/config.toml | |
| # | |
| # Profiles from both locations are merged into a single namespace — if the | |
| # same name is defined in both, COI will refuse to start and ask you to | |
| # rename one. | |
| # | |
| # Default profile directory scan locations: | |
| # 1. ~/.coi/profiles/NAME/config.toml | |
| # 2. .coi/profiles/NAME/config.toml | |
| # | |
| # Additional scan location when COI_CONFIG is set: | |
| # 3. dirname($COI_CONFIG)/profiles/NAME/config.toml | |
| # | |
| # Profiles from all scanned locations are merged into a single namespace — if | |
| # the same name is defined in more than one location, COI will refuse to | |
| # start and ask you to rename one. | |
| # |
| Profiles can live under either of: | ||
| - ~/.coi/profiles/ | ||
| - ./.coi/profiles/ (project-local) | ||
|
|
||
| Profiles from both locations are merged together. If the same profile name is |
There was a problem hiding this comment.
Module docstring lists the supported profile locations as an exhaustive set, but the Go implementation also scans dirname($COI_CONFIG) when COI_CONFIG is set. Consider mentioning that optional location here (or changing wording) so the test documentation matches behavior.
| Profiles can live under either of: | |
| - ~/.coi/profiles/ | |
| - ./.coi/profiles/ (project-local) | |
| Profiles from both locations are merged together. If the same profile name is | |
| This module covers these profile locations: | |
| - ~/.coi/profiles/ | |
| - ./.coi/profiles/ (project-local) | |
| The implementation may also scan dirname($COI_CONFIG) when COI_CONFIG is set, | |
| but those scenarios are not exercised by these tests. Profiles from the home | |
| and project locations are merged together. If the same profile name is |
The enable_monitoring and enable_monitoring_low_thresholds fixtures in test_security_monitoring.py were writing config to ~/.config/coi/config.toml which is no longer read after the config location simplification. Update both fixtures to write to ~/.coi/config.toml. Also address Copilot review comments: mention dirname($COI_CONFIG) scan location in README, embedded example docs, and Python test docstrings.
Summary
Profiles can now live under any of
/etc/coi/profiles/,~/.config/coi/profiles/,~/.coi/profiles/, or./.coi/profiles/. All locations are merged into a single namespace so users can keep profiles alongside their XDG config or alongside runtime data (~/.coi/— same parent as sessions/storage/logs).Duplicate detection: If the same profile name is defined in more than one location, COI refuses to start and points to both files, asking the user to rename one. This keeps it unambiguous which profile is actually being applied and avoids silent override surprises across scan locations.
Motivation
Previously only
~/.config/coi/profiles/was scanned from the user home area. Users who naturally placed profiles under~/.coi/profiles/(alongside sessions/storage/logs, which live under~/.coi/) would see them silently ignored. This PR makes both locations work out of the box.Changes
internal/config/config.go— newGetProfileParentDirs()helper returning all scan locations (/etc/coi,~/.config/coi,~/.coi,./.coi, and$COI_CONFIGparent dir if set)internal/config/loader.go—Load()now iteratesGetProfileParentDirs()for profile scanning, independently from config-file loading.loadProfileDirectories()rejects duplicate profile names across scan locations with a clear error pointing to both source filesREADME.md+ embeddedWriteExampledocs updated to list all four locations and explain the merge + duplicate-detection behaviorCHANGELOG.mdentry under ImprovementsTests
Go unit tests (
internal/config/loader_test.go+config_test.go):TestGetProfileParentDirs/…WithCoiConfig/…IncludesHomeCoiTestLoadProfilesFromHomeCoi— ~/.coi/profiles/ loads via Load()TestLoadMergesProfilesFromBothHomeLocations— XDG + ~/.coi mergedTestLoadMergesProjectAndHomeProfiles— XDG + ~/.coi + project .coi mergedTestLoadDuplicateProfileAcrossLocationsFails— home-vs-home duplicate errorsTestLoadDuplicateProfileProjectVsHomeFails— project-vs-home duplicate errorsTestLoadProfileDirectoriesDuplicateNameError— direct loader testPython integration tests (
tests/config/test_profile_home_coi_dir.py) using a fakeHOME:test_profile_list_from_home_coi_dirtest_profile_info_from_home_coi_dirtest_profile_merges_all_locations— XDG + ~/.coi + project .coi all listed and resolvabletest_profile_duplicate_name_across_home_locations_failstest_profile_duplicate_name_project_vs_home_failsTest plan
go build ./...go vet ./...go test ./internal/config/python -m pytest tests/config/test_profile_home_coi_dir.py -v(5 passed)python -m pytest tests/config/test_profile_directory.py tests/config/test_profile_list.py tests/config/test_profile_inheritance_cli.py tests/config/test_default_profile.py(12 existing profile tests still green — no regressions)ruff check tests/config/test_profile_home_coi_dir.py+ruff format --check