diff --git a/.claude/commands/audit-skills.md b/.claude/commands/audit-skills.md new file mode 100644 index 0000000..34d6f6e --- /dev/null +++ b/.claude/commands/audit-skills.md @@ -0,0 +1,16 @@ +Review the iconmate skill definition at `skills/iconmate/SKILL.md` and verify it accurately reflects the current state of the CLI. + +## Steps + +1. Read `skills/iconmate/SKILL.md` in full. +2. Run `iconmate --help` and `iconmate add --help`, `iconmate delete --help`, `iconmate list --help`, `iconmate iconify --help` to get the current CLI interface. +3. Read the project `README.md` for any features or commands not covered in the skill. +4. Read `Cargo.toml` for the current version number. +5. Compare and report: + - **Missing commands**: CLI commands not documented in the skill. + - **Outdated flags**: Flags in the skill that no longer exist or have changed. + - **Wrong defaults**: Default values that don't match current behavior. + - **Version mismatch**: If the skill metadata version doesn't match `Cargo.toml`. + - **Missing features**: Significant capabilities described in the README but absent from the skill. +6. Fix any issues found directly in `skills/iconmate/SKILL.md`. +7. Print a summary of what was checked and any changes made. diff --git a/Justfile b/Justfile index 22cc9c8..936c068 100644 --- a/Justfile +++ b/Justfile @@ -14,3 +14,6 @@ tag: tag_and_release tag_and_release: sh tag_and_release.sh + +sync_readme: + cp README.md npm/README.md diff --git a/README.md b/README.md index 941aa59..38e7830 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,14 @@ iconmate add --folder src/assets/icons --icon heroicons:heart --name Heart --out iconmate delete --folder src/assets/icons ``` +### List current icons + +```bash +iconmate list --folder src/assets/icons +# or use the default folder (src/assets/icons) +iconmate list +``` + ### Iconify API Commands ```bash @@ -278,6 +286,14 @@ iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify. This means an AI can search, choose, and add icons without opening a browser. +#### Claude Code Skill + +For the best AI experience, install the [iconmate skill](https://github.com/Blankeos/iconmate/tree/main/skills/iconmate) so your agent knows all the commands automatically: + +```bash +npx skills add Blankeos/iconmate +``` + ### Package.json Scripts Best practice: Add sensible defaults to your script runner. diff --git a/npm/README.md b/npm/README.md index 24d875b..c61b0aa 100644 --- a/npm/README.md +++ b/npm/README.md @@ -233,6 +233,14 @@ iconmate add --folder src/assets/icons --icon heroicons:heart --name Heart --out iconmate delete --folder src/assets/icons ``` +### List current icons + +```bash +iconmate list --folder src/assets/icons +# or use the default folder (src/assets/icons) +iconmate list +``` + ### Iconify API Commands ```bash diff --git a/skills/iconmate/SKILL.md b/skills/iconmate/SKILL.md new file mode 100644 index 0000000..63297f5 --- /dev/null +++ b/skills/iconmate/SKILL.md @@ -0,0 +1,132 @@ +--- +name: iconmate +description: >- + Use when the user asks to add, search, list, or manage SVG icons in a JS/TS project. + Trigger phrases: "add an icon", "search for icons", "find an icon", "add svg", + "icon for heart", "list icons", "delete icon", "rename icon", "iconmate", "iconify". +metadata: + author: blankeos + version: "1.1.0" + repository: https://github.com/Blankeos/iconmate +license: MIT +--- + +# iconmate + +Add SVG icons to JS/TS projects without icon libraries. Uses Iconify's 200k+ icons or any SVG source. + +## Prerequisites + +`iconmate` must be installed. If not available, install with any of these methods: + +```bash +# npm / pnpm / bun (prebuilt binary via npm) +npm install -g iconmate +pnpm add -g iconmate +bun add -g iconmate + +# Run without installing +npx iconmate +pnpm dlx iconmate +bunx iconmate + +# Cargo (build from source) +cargo install iconmate + +# Cargo binstall (prebuilt binary via cargo) +cargo binstall iconmate +``` + +## Workflow + +Follow these steps when the user wants to add icons: + +### 1. Search for icons + +```bash +iconmate iconify search --format json --limit 20 --include-collections +``` + +Present results to the user and let them pick. + +### 2. Add the icon + +```bash +iconmate add --folder --icon --name +``` + +- `--folder`: Icon output directory (default: `src/assets/icons`). Check `iconmate.config.json` or `iconmate.config.jsonc` in the project root for a configured folder. +- `--icon`: Accepts an Iconify name (e.g. `mdi:heart`), a URL, or raw SVG markup. +- `--name`: PascalCase alias used in the export (e.g. `Heart`). +- `--preset`: Framework preset. Check config for the project default. + - `normal` → `.svg` (plain SVG file) + - `react` → `.tsx` (React component) + - `svelte` → `.svelte` (Svelte component) + - `solid` → `.tsx` (SolidJS component) + - `vue` → `.vue` (Vue component) + - `emptysvg` → `.svg` (empty placeholder SVG) + +### 3. Verify + +```bash +iconmate list --folder +``` + +Confirm the icon appears in the list and the `index.ts` export file is updated. + +## Other Commands + +### Delete an icon + +```bash +iconmate delete --folder +``` + +### List current icons + +```bash +iconmate list +``` + +### Browse Iconify collections + +```bash +# List all collections +iconmate iconify collections + +# List icons in a collection +iconmate iconify collection mdi + +# Get raw SVG for one icon +iconmate iconify get mdi:heart +``` + +### Add from URL or raw SVG + +```bash +# From URL +iconmate add --folder src/assets/icons --icon https://api.iconify.design/mdi:heart.svg --name Heart + +# From raw SVG +iconmate add --folder src/assets/icons --icon '...' --name Heart +``` + +### Custom export template + +```bash +iconmate add --folder src/assets/icons --icon mdi:heart --name Heart \ + --output-line-template "export { ReactComponent as Icon%name% } from './%icon%.svg?react';" +``` + +Template variables: `%name%` (PascalCase alias), `%icon%` (filename without extension), `%ext%` (file extension). + +## Configuration + +Check for `iconmate.config.json` or `iconmate.config.jsonc` in the project root. If present, respect its `folder`, `preset`, and `output_line_template` values. CLI flags override config values. + +## Tips + +- Always use `--format json` when searching so you can parse results programmatically. +- When adding multiple icons, run each `iconmate add` command separately. The export index is updated automatically after each add. +- For prototyping, use `--preset emptysvg` to create placeholder icons. +- The user imports icons like: `import { IconHeart } from "@/assets/icons";` diff --git a/src/config.rs b/src/config.rs index 0e2f390..4265ac5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -169,7 +169,12 @@ fn load_global_config( ) -> anyhow::Result>> { let mut candidates = Vec::::new(); - if let Some(config_dir) = dirs::config_dir() { + // Use $XDG_CONFIG_HOME if set, otherwise ~/.config — consistent across all platforms. + let config_dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|h| h.join(".config"))); + + if let Some(config_dir) = config_dir { candidates.push(config_dir.join("iconmate.jsonc")); candidates.push(config_dir.join("iconmate.json")); candidates.push(config_dir.join("iconmate").join("config.jsonc")); diff --git a/src/main.rs b/src/main.rs index ece5c0e..77021c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,6 +95,14 @@ enum Commands { folder: Option, }, + /// List all icons currently exported in the icons folder. + #[command(visible_alias = "ls")] + List { + /// Pathname of the folder where all the icons are saved. + #[arg(long)] + folder: Option, + }, + /// Query Iconify collections, search results, and raw SVGs. Iconify { #[command(subcommand)] @@ -529,11 +537,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { if index_ts_path.exists() { let existing_index = fs::read_to_string(&index_ts_path)?; - validate_new_export_conflicts( - &existing_index, - &rendered_export_statement, - &index_ts_path, - )?; + validate_new_export_conflicts(&existing_index, &rendered_export_statement, &index_ts_path)?; } if svg_file_path.exists() { @@ -801,6 +805,37 @@ fn remove_selected_exports_from_index(contents: &str, selected_icons: &[IconEntr updated } +fn resolve_list_folder<'a>( + cli: &'a CliArgs, + command_folder: Option<&'a PathBuf>, +) -> Option<&'a PathBuf> { + command_folder.or(cli.folder.as_ref()) +} + +fn run_list_mode(cli: &CliArgs, command_folder: Option<&PathBuf>) -> anyhow::Result<()> { + let folder = resolve_list_folder(cli, command_folder) + .cloned() + .unwrap_or_else(|| PathBuf::from(config::DEFAULT_FOLDER)); + let index_ts_path = folder.join("index.ts"); + + if !index_ts_path.exists() { + println!("No icons found in {}", index_ts_path.display()); + return Ok(()); + } + + let icons = crate::utils::get_existing_icons(folder.to_string_lossy().as_ref())?; + if icons.is_empty() { + println!("No icons found in {}", index_ts_path.display()); + return Ok(()); + } + + for icon in icons { + println!("{}\t{}", icon.name, icon.file_path); + } + + Ok(()) +} + /// Interactive mode: deleting an icon from a select list of icons. fn resolve_delete_folder<'a>( cli: &'a CliArgs, @@ -922,6 +957,7 @@ async fn main() -> anyhow::Result<()> { Some(Commands::Delete { ref folder }) => { run_delete_prompt_mode(&args, folder.as_ref()).await } + Some(Commands::List { ref folder }) => run_list_mode(&args, folder.as_ref()), Some(Commands::Iconify { command }) => run_iconify_command(command).await, None => { let resolved = config::resolve_tui_config( @@ -1036,6 +1072,41 @@ mod tests { assert_eq!(resolved, Some(&cli_folder)); } + #[test] + fn resolve_list_folder_prefers_subcommand_folder() { + let cli_folder = PathBuf::from("src/assets/icons"); + let command_folder = PathBuf::from("icons/from/list"); + let cli = CliArgs { + command: None, + folder: Some(cli_folder), + preset: None, + name: None, + icon: None, + filename: None, + output_line_template: None, + }; + + let resolved = resolve_list_folder(&cli, Some(&command_folder)); + assert_eq!(resolved, Some(&command_folder)); + } + + #[test] + fn resolve_list_folder_falls_back_to_global_folder() { + let cli_folder = PathBuf::from("src/assets/icons"); + let cli = CliArgs { + command: None, + folder: Some(cli_folder.clone()), + preset: None, + name: None, + icon: None, + filename: None, + output_line_template: None, + }; + + let resolved = resolve_list_folder(&cli, None); + assert_eq!(resolved, Some(&cli_folder)); + } + #[test] fn validate_new_export_conflicts_rejects_duplicate_alias() { let existing = "export { default as IconHeart } from './heart.svg';\n"; @@ -1046,7 +1117,11 @@ mod tests { ) .expect_err("duplicate alias should fail"); - assert!(error.to_string().contains("Icon alias 'IconHeart' already exists")); + assert!( + error + .to_string() + .contains("Icon alias 'IconHeart' already exists") + ); } #[test] @@ -1059,9 +1134,11 @@ mod tests { ) .expect_err("duplicate target should fail"); - assert!(error - .to_string() - .contains("Export target './heart.svg' already exists")); + assert!( + error + .to_string() + .contains("Export target './heart.svg' already exists") + ); } #[test] diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 142be7b..63c14aa 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -467,3 +467,99 @@ fn test_add_command_preset_normal_requires_icon() { "stderr should explain normal preset needs an icon" ); } + +#[test] +fn test_list_command_prints_existing_icons() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let test_folder = temp_dir.path().join("src/assets/icons"); + std::fs::create_dir_all(&test_folder).expect("Failed to create icons folder"); + + let index_file = test_folder.join("index.ts"); + std::fs::write( + &index_file, + "export { default as IconHeart } from './heart.svg';\nexport { default as IconStar } from './star.svg';\n", + ) + .expect("Failed to write index.ts"); + + let binary_path = env!("CARGO_BIN_EXE_iconmate"); + let output = Command::new(binary_path) + .args(["list", "--folder", test_folder.to_str().unwrap()]) + .current_dir(temp_dir.path()) + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "Command failed with stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("IconHeart\t./heart.svg"), + "stdout should include IconHeart row" + ); + assert!( + stdout.contains("IconStar\t./star.svg"), + "stdout should include IconStar row" + ); +} + +#[test] +fn test_list_command_uses_default_folder_when_no_flag_is_passed() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let default_folder = temp_dir.path().join("src/assets/icons"); + std::fs::create_dir_all(&default_folder).expect("Failed to create icons folder"); + + let index_file = default_folder.join("index.ts"); + std::fs::write( + &index_file, + "export { default as IconHouse } from './house.svg';\n", + ) + .expect("Failed to write index.ts"); + + let binary_path = env!("CARGO_BIN_EXE_iconmate"); + let output = Command::new(binary_path) + .args(["list"]) + .current_dir(temp_dir.path()) + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "Command failed with stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("IconHouse\t./house.svg"), + "stdout should include icon from default folder" + ); +} + +#[test] +fn test_list_command_reports_no_icons_when_index_is_missing() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let test_folder = temp_dir.path().join("src/assets/icons"); + std::fs::create_dir_all(&test_folder).expect("Failed to create icons folder"); + + let binary_path = env!("CARGO_BIN_EXE_iconmate"); + let output = Command::new(binary_path) + .args(["list", "--folder", test_folder.to_str().unwrap()]) + .current_dir(temp_dir.path()) + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "Command failed with stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("No icons found in"), + "stdout should explain that no icons were found" + ); +}