diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1c65245..7ddf074 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -51,6 +51,41 @@ jobs:
name: core-test-results
path: TestResults/
+ importer-tests:
+ name: Importer build + tests (ubuntu)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+ cache: true
+ cache-dependency-path: |
+ **/packages.lock.json
+ **/*.csproj
+
+ - name: Restore importer projects
+ run: |
+ dotnet restore tools/Snipdeck.Importer/Snipdeck.Importer.csproj
+ dotnet restore tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj
+
+ - name: Build importer
+ run: dotnet build tools/Snipdeck.Importer/Snipdeck.Importer.csproj --configuration Release --no-restore
+
+ - name: Build + run importer tests
+ run: dotnet test tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=importer-tests.trx" --results-directory TestResults
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: importer-test-results
+ path: TestResults/
+
app-build:
name: App build (windows)
runs-on: windows-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 952d441..b414d6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
IL2104 on the WinAppSDK/WinRT/Jdenticon assemblies, which aren't trim-safe.
### Added
+- **`snipdeck-importer` console tool.** A new cross-platform .NET tool
+ (`tools/Snipdeck.Importer`, installable as `snipdeck-importer`) imports command
+ snippets from a SnipCommand export into a Snipdeck store. It auto-suggests each
+ CLI from the command's first token, translates SnipCommand's inline
+ `[sc_choice]` / `[sc_variable]` markup into Snipdeck `{token}` placeholders plus
+ structured parameters, and defaults to a safe dry-run — `--write` backs the
+ store up first, mints fresh identifiers, creates CLIs on demand and skips
+ duplicates (`--allow-duplicates` to override). `--store`, `--cli` and `--into`
+ control the target. See [`tools/Snipdeck.Importer/README.md`](tools/Snipdeck.Importer/README.md).
+- **Move a snip to a different CLI.** The snip editor now has a **CLI** selector,
+ so a snip can be re-homed to another CLI (useful after a bulk import lands snips
+ in a fallback CLI).
+- **Discoverable importer in the first-run seed.** A new first-run **`snipdeck-importer`**
+ CLI sits alongside **Examples**, with snips that demonstrate the importer's own
+ commands.
- **Redesigned Home page.** A gradient hero banner heads the page, the CLI
launcher uses landscape tiles (232×172) showing each CLI's description, and a
segmented selector below switches between **Most used**, **Recent** and
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d0e5d0f..4540b15 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,7 +24,13 @@
-
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index cce78d5..015b956 100644
--- a/README.md
+++ b/README.md
@@ -46,16 +46,26 @@ on Windows 10 it falls back to a solid colour.
```
src/
- Snipdeck.Core/ net10.0 — UI-free domain, engine, store, services
- Snipdeck.App/ net10.0-windows — WinUI 3 head, platform implementations
+ Snipdeck.Core/ net10.0 — UI-free domain, engine, store, services
+ Snipdeck.App/ net10.0-windows — WinUI 3 head, platform implementations
tests/
- Snipdeck.Core.Tests/ net10.0 — xUnit coverage for Core
+ Snipdeck.Core.Tests/ net10.0 — xUnit coverage for Core
+tools/
+ Snipdeck.Importer/ net10.0 — snipdeck-importer console tool (SnipCommand import)
+ Snipdeck.Importer.Tests/ net10.0 — xUnit coverage for the importer
```
The dependency direction is one-way: `App → Core`. The view models live in Core
and never touch WinUI types directly — every platform-bound capability is an
interface defined in Core and implemented in App.
+## Importing from SnipCommand
+
+Migrating from SnipCommand? The cross-platform `snipdeck-importer` console tool
+reads a SnipCommand export and merges its snippets into your Snipdeck store,
+grouping them under a CLI per command. It defaults to a dry-run preview; see
+[`tools/Snipdeck.Importer/README.md`](tools/Snipdeck.Importer/README.md).
+
## Building
Requirements:
@@ -75,7 +85,8 @@ dotnet test tests/Snipdeck.Core.Tests
The `Snipdeck.Core` project targets `net10.0` and is fully portable, so
`dotnet build` / `dotnet test` for Core also work on Linux and macOS. The
-`Snipdeck.App` project is Windows-only.
+`Snipdeck.App` project is Windows-only. The `Snipdeck.Importer` tool also targets
+`net10.0` and builds, tests and runs on any platform.
## Licence
diff --git a/Snipdeck.slnx b/Snipdeck.slnx
index bfbea95..cc8d293 100644
--- a/Snipdeck.slnx
+++ b/Snipdeck.slnx
@@ -4,4 +4,6 @@
+
+
diff --git a/src/Snipdeck.App/Services/WindowsPathProvider.cs b/src/Snipdeck.App/Services/WindowsPathProvider.cs
index e70ab19..a2103ce 100644
--- a/src/Snipdeck.App/Services/WindowsPathProvider.cs
+++ b/src/Snipdeck.App/Services/WindowsPathProvider.cs
@@ -1,37 +1,23 @@
using Snipdeck.Core.Abstractions;
+using Snipdeck.Core.Services;
namespace Snipdeck.App.Services
{
///
/// Resolves Snipdeck's data paths under %LOCALAPPDATA%\Snipdeck.
+ /// The layout lives in (Core) so the cross-platform
+ /// importer tool resolves identical locations; this provider simply delegates.
///
internal sealed class WindowsPathProvider : IPathProvider
{
- private const string _appFolderName = "Snipdeck";
- private const string _settingsFileName = "settings.json";
- private const string _storeDirectoryName = "store";
- private const string _backupsDirectoryName = "backups";
- private const string _logsDirectoryName = "logs";
+ public string AppDataDirectory => DefaultPaths.AppDataDirectory;
- public WindowsPathProvider()
- {
- AppDataDirectory = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- _appFolderName);
- SettingsFilePath = Path.Combine(AppDataDirectory, _settingsFileName);
- DefaultStorageDirectory = Path.Combine(AppDataDirectory, _storeDirectoryName);
- DefaultBackupDirectory = Path.Combine(AppDataDirectory, _backupsDirectoryName);
- LogsDirectory = Path.Combine(AppDataDirectory, _logsDirectoryName);
- }
+ public string SettingsFilePath => DefaultPaths.SettingsFilePath;
- public string AppDataDirectory { get; }
+ public string DefaultStorageDirectory => DefaultPaths.DefaultStorageDirectory;
- public string SettingsFilePath { get; }
+ public string DefaultBackupDirectory => DefaultPaths.DefaultBackupDirectory;
- public string DefaultStorageDirectory { get; }
-
- public string DefaultBackupDirectory { get; }
-
- public string LogsDirectory { get; }
+ public string LogsDirectory => DefaultPaths.LogsDirectory;
}
}
diff --git a/src/Snipdeck.App/Services/WindowsShellInteractions.cs b/src/Snipdeck.App/Services/WindowsShellInteractions.cs
index e669c2c..1104470 100644
--- a/src/Snipdeck.App/Services/WindowsShellInteractions.cs
+++ b/src/Snipdeck.App/Services/WindowsShellInteractions.cs
@@ -72,7 +72,7 @@ public async Task NotifyAsync(string title, string message, string buttonText =
public async Task EditSnipAsync(Snip snip, IReadOnlyList availableClis)
{
ArgumentNullException.ThrowIfNull(snip);
- var editor = new SnipEditorViewModel(snip);
+ var editor = new SnipEditorViewModel(snip, availableClis);
var dialog = new SnipEditorDialog(editor)
{
XamlRoot = GetXamlRoot(),
diff --git a/src/Snipdeck.App/Views/SnipEditorDialog.xaml b/src/Snipdeck.App/Views/SnipEditorDialog.xaml
index 06365f1..7edd8ff 100644
--- a/src/Snipdeck.App/Views/SnipEditorDialog.xaml
+++ b/src/Snipdeck.App/Views/SnipEditorDialog.xaml
@@ -26,6 +26,18 @@
+
+
+
+
+
+
+
+
+
+ /// Normalises a comma-separated tag string into a tag list: split on commas, trim, drop
+ /// empties, and de-duplicate case-insensitively. Shared by the snip editor and the importer
+ /// so a tag list is normalised the same way wherever it enters the store.
+ ///
+ public static class TagParser
+ {
+ public static List Parse(string? text)
+ {
+ return string.IsNullOrWhiteSpace(text)
+ ? []
+ : [.. text
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Distinct(StringComparer.OrdinalIgnoreCase)];
+ }
+ }
+}
diff --git a/src/Snipdeck.Core/Services/DefaultPaths.cs b/src/Snipdeck.Core/Services/DefaultPaths.cs
new file mode 100644
index 0000000..998909d
--- /dev/null
+++ b/src/Snipdeck.Core/Services/DefaultPaths.cs
@@ -0,0 +1,50 @@
+namespace Snipdeck.Core.Services
+{
+ ///
+ /// Resolves Snipdeck's default data paths under %LOCALAPPDATA%\Snipdeck (Windows)
+ /// or the platform equivalent of .
+ ///
+ /// This lives in Core so that both the WinUI head and the cross-platform importer tool
+ /// resolve the same locations without duplicating the layout. The desktop app's
+ /// WindowsPathProvider delegates here, so its behaviour is unchanged.
+ ///
+ ///
+ public static class DefaultPaths
+ {
+ public const string AppFolderName = "Snipdeck";
+ public const string SettingsFileName = "settings.json";
+ public const string StoreDirectoryName = "store";
+ public const string StoreFileName = "store.json";
+ public const string BackupsDirectoryName = "backups";
+ public const string LogsDirectoryName = "logs";
+
+ /// The Snipdeck data root, e.g. %LOCALAPPDATA%\Snipdeck.
+ public static string AppDataDirectory { get; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ AppFolderName);
+
+ /// The app-config file, stored separately from the snip store.
+ public static string SettingsFilePath { get; } = Path.Combine(AppDataDirectory, SettingsFileName);
+
+ /// The default directory that holds the snip store document.
+ public static string DefaultStorageDirectory { get; } = Path.Combine(AppDataDirectory, StoreDirectoryName);
+
+ /// The default directory that holds timestamped store backups.
+ public static string DefaultBackupDirectory { get; } = Path.Combine(AppDataDirectory, BackupsDirectoryName);
+
+ /// The default directory that holds application logs.
+ public static string LogsDirectory { get; } = Path.Combine(AppDataDirectory, LogsDirectoryName);
+
+ ///
+ /// Composes the full default store-document path (<storage>/store.json) from an
+ /// optional configured storage directory, falling back to .
+ ///
+ public static string ResolveStoreFilePath(string? configuredStorageDirectory)
+ {
+ var storageDirectory = string.IsNullOrWhiteSpace(configuredStorageDirectory)
+ ? DefaultStorageDirectory
+ : configuredStorageDirectory;
+ return Path.Combine(storageDirectory, StoreFileName);
+ }
+ }
+}
diff --git a/src/Snipdeck.Core/Services/ExamplesSeed.cs b/src/Snipdeck.Core/Services/ExamplesSeed.cs
index 9aa3fcf..18b73f5 100644
--- a/src/Snipdeck.Core/Services/ExamplesSeed.cs
+++ b/src/Snipdeck.Core/Services/ExamplesSeed.cs
@@ -6,6 +6,7 @@ namespace Snipdeck.Core.Services
public static class ExamplesSeed
{
public const string CliName = "Examples";
+ public const string ImporterCliName = "snipdeck-importer";
public static bool IsEmpty(SnipStoreDocument document)
{
@@ -14,13 +15,22 @@ public static bool IsEmpty(SnipStoreDocument document)
}
public static SnipStoreDocument Build()
+ {
+ var document = new SnipStoreDocument();
+ AddExamplesCli(document);
+ AddImporterCli(document);
+
+ ValidateInternalConsistency(document);
+ return document;
+ }
+
+ private static void AddExamplesCli(SnipStoreDocument document)
{
var cli = new Cli
{
Name = CliName,
Description = "A starter CLI with a few representative snips. Delete it once you're oriented.",
};
- var document = new SnipStoreDocument();
document.Clis.Add(cli);
document.Snips.Add(new Snip
@@ -84,9 +94,83 @@ public static SnipStoreDocument Build()
},
},
});
+ }
- ValidateInternalConsistency(document);
- return document;
+ private static void AddImporterCli(SnipStoreDocument document)
+ {
+ var cli = new Cli
+ {
+ Name = ImporterCliName,
+ Description = "The Snipdeck importer command-line tool. Bring snips in from SnipCommand and other sources.",
+ };
+ document.Clis.Add(cli);
+
+ document.Snips.Add(new Snip
+ {
+ CliId = cli.Id,
+ Title = "Preview a SnipCommand import",
+ CommandTemplate = "snipdeck-importer snipcommand {path}",
+ Description = "Parses a SnipCommand export and prints the planned additions without touching the store. The safe default — nothing is written until you add --write.",
+ Tags = { "import", "snipcommand" },
+ Parameters =
+ {
+ new Parameter
+ {
+ Name = "path",
+ Type = ParameterType.Text,
+ Default = "snipcommand.db",
+ },
+ },
+ });
+
+ document.Snips.Add(new Snip
+ {
+ CliId = cli.Id,
+ Title = "Import from SnipCommand",
+ CommandTemplate = "snipdeck-importer snipcommand {path} --write",
+ Description = "Merges the SnipCommand export into the Snipdeck store. Backs the store up first, mints fresh identifiers, and skips snips that already exist.",
+ Tags = { "import", "snipcommand" },
+ IsFavourite = true,
+ Parameters =
+ {
+ new Parameter
+ {
+ Name = "path",
+ Type = ParameterType.Text,
+ Default = "snipcommand.db",
+ },
+ },
+ });
+
+ document.Snips.Add(new Snip
+ {
+ CliId = cli.Id,
+ Title = "Import into a specific store and CLI",
+ CommandTemplate = "snipdeck-importer snipcommand {path} --store {store} --cli {cli}",
+ Description = "Imports into an explicit store file and forces every snip into one named CLI, overriding the per-command auto-suggestion.",
+ Tags = { "import", "snipcommand" },
+ Parameters =
+ {
+ new Parameter
+ {
+ Name = "path",
+ Type = ParameterType.Text,
+ Default = "snipcommand.db",
+ },
+ new Parameter
+ {
+ Name = "store",
+ Type = ParameterType.Text,
+ Default = "store.json",
+ },
+ new Parameter
+ {
+ Name = "cli",
+ Type = ParameterType.Text,
+ Default = "imported",
+ },
+ },
+ });
}
private static void ValidateInternalConsistency(SnipStoreDocument document)
diff --git a/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs b/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs
index b08ed37..5d9d1ee 100644
--- a/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs
@@ -2,13 +2,14 @@
using CommunityToolkit.Mvvm.ComponentModel;
+using Snipdeck.Core.Engine;
using Snipdeck.Core.Models;
namespace Snipdeck.Core.ViewModels
{
public sealed partial class SnipEditorViewModel : ObservableObject
{
- public SnipEditorViewModel(Snip snip)
+ public SnipEditorViewModel(Snip snip, IReadOnlyList? availableClis = null)
{
ArgumentNullException.ThrowIfNull(snip);
@@ -19,10 +20,23 @@ public SnipEditorViewModel(Snip snip)
TagsText = string.Join(", ", snip.Tags);
Parameters = new ObservableCollection(
snip.Parameters.Select(p => new ParameterEditorRowViewModel(p)));
+
+ AvailableClis = new ObservableCollection(availableClis ?? []);
+ SelectedCli = AvailableClis.FirstOrDefault(c => c.Id == snip.CliId);
}
public Snip Snip { get; }
+ /// The CLIs the snip may belong to, so the user can re-home it.
+ public ObservableCollection AvailableClis { get; }
+
+ ///
+ /// The CLI the snip will belong to once saved. Initialised from the snip's current
+ /// CLI; changing it moves the snip to a different CLI.
+ ///
+ [ObservableProperty]
+ public partial Cli? SelectedCli { get; set; }
+
[ObservableProperty]
public partial string Title { get; set; } = string.Empty;
@@ -55,11 +69,11 @@ public Snip BuildUpdatedSnip()
return new Snip
{
Id = Snip.Id,
- CliId = Snip.CliId,
+ CliId = SelectedCli?.Id ?? Snip.CliId,
Title = Title.Trim(),
CommandTemplate = CommandTemplate.Trim(),
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
- Tags = ParseTags(TagsText),
+ Tags = TagParser.Parse(TagsText),
IsFavourite = Snip.IsFavourite,
IsTrash = Snip.IsTrash,
UsageCount = Snip.UsageCount,
@@ -67,14 +81,5 @@ public Snip BuildUpdatedSnip()
Parameters = [.. Parameters.Select(r => r.BuildParameter())],
};
}
-
- private static List ParseTags(string text)
- {
- return string.IsNullOrWhiteSpace(text)
- ? []
- : [.. text
- .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
- .Distinct(StringComparer.OrdinalIgnoreCase)];
- }
}
}
diff --git a/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs b/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs
index 591462e..3a7fa58 100644
--- a/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs
+++ b/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs
@@ -20,23 +20,28 @@ public void IsEmpty_is_false_after_Build()
}
[Fact]
- public void Build_produces_a_single_cli_named_Examples()
+ public void Build_produces_the_examples_and_importer_clis()
{
var doc = ExamplesSeed.Build();
- var cli = Assert.Single(doc.Clis);
- Assert.Equal(ExamplesSeed.CliName, cli.Name);
- Assert.NotEqual(Guid.Empty, cli.Id);
+ Assert.Equal(2, doc.Clis.Count);
+ Assert.Contains(doc.Clis, c => c.Name == ExamplesSeed.CliName);
+ Assert.Contains(doc.Clis, c => c.Name == ExamplesSeed.ImporterCliName);
+ Assert.All(doc.Clis, c => Assert.NotEqual(Guid.Empty, c.Id));
+ Assert.Equal(doc.Clis.Count, doc.Clis.Select(c => c.Id).Distinct().Count());
}
[Fact]
- public void Build_produces_multiple_snips_all_belonging_to_the_examples_cli()
+ public void Build_produces_snips_that_each_belong_to_a_seeded_cli()
{
var doc = ExamplesSeed.Build();
- var cliId = doc.Clis.Single().Id;
+ var cliIds = doc.Clis.Select(c => c.Id).ToHashSet();
Assert.NotEmpty(doc.Snips);
- Assert.All(doc.Snips, snip => Assert.Equal(cliId, snip.CliId));
+ Assert.All(doc.Snips, snip => Assert.Contains(snip.CliId, cliIds));
+
+ // Both seeded CLIs carry at least one snip.
+ Assert.All(doc.Clis, cli => Assert.Contains(doc.Snips, s => s.CliId == cli.Id));
}
[Fact]
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs
index c4f8da4..fb0031f 100644
--- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs
+++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs
@@ -174,6 +174,42 @@ public async Task EditSnip_replaces_the_snip_in_the_store_when_interactions_retu
Assert.Equal(1, store.SaveCount);
}
+ [Fact]
+ public async Task EditSnip_moves_the_snip_to_a_different_cli_when_the_cli_id_changes()
+ {
+ Cli source = null!;
+ Snip snip = null!;
+ var target = new Cli { Name = "other-app" };
+ var (vm, store, _, ix, _) = await BuildAsync(d =>
+ {
+ (source, snip) = SeedOneCliOneSnip(d);
+ d.Clis.Add(target);
+ });
+
+ // The editor returns the snip re-homed to a different CLI.
+ ix.NextSnipEditResult = new SnipEditResult(new Snip
+ {
+ Id = snip.Id,
+ CliId = target.Id,
+ Title = snip.Title,
+ CommandTemplate = snip.CommandTemplate,
+ });
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == source.Id);
+ var card = ((CliViewModel)vm.CurrentContent!).Snips[0];
+
+ await vm.EditSnipCommand.ExecuteAsync(card);
+
+ Assert.Equal(target.Id, store.Document.Snips[0].CliId);
+
+ // The moved snip no longer appears under its old CLI.
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == source.Id);
+ Assert.Empty(((CliViewModel)vm.CurrentContent!).Snips);
+
+ // ...and now appears under the target CLI.
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == target.Id);
+ Assert.Single(((CliViewModel)vm.CurrentContent!).Snips);
+ }
+
[Fact]
public async Task DeleteSnip_marks_as_trash_only_when_confirmed()
{
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs
index 1f6d62f..a5b42ed 100644
--- a/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs
+++ b/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs
@@ -107,5 +107,43 @@ public void BuildUpdatedSnip_preserves_identity_and_applies_edits()
Assert.Single(built.Parameters);
Assert.Equal("x", built.Parameters[0].Name);
}
+
+ [Fact]
+ public void SelectedCli_initialises_from_the_snips_current_cli()
+ {
+ var examples = new Cli { Name = "Examples" };
+ var other = new Cli { Name = "other" };
+ var snip = new Snip { CliId = other.Id, Title = "t", CommandTemplate = "echo" };
+
+ var vm = new SnipEditorViewModel(snip, [examples, other]);
+
+ Assert.Same(other, vm.SelectedCli);
+ }
+
+ [Fact]
+ public void BuildUpdatedSnip_moves_the_snip_to_the_selected_cli()
+ {
+ var source = new Cli { Name = "source" };
+ var target = new Cli { Name = "target" };
+ var snip = new Snip { CliId = source.Id, Title = "t", CommandTemplate = "echo" };
+ var vm = new SnipEditorViewModel(snip, [source, target])
+ {
+ SelectedCli = target,
+ };
+
+ var built = vm.BuildUpdatedSnip();
+
+ Assert.Equal(target.Id, built.CliId);
+ }
+
+ [Fact]
+ public void BuildUpdatedSnip_keeps_the_original_cli_when_no_clis_are_supplied()
+ {
+ var snip = new Snip { CliId = Guid.NewGuid(), Title = "t", CommandTemplate = "echo" };
+
+ var built = new SnipEditorViewModel(snip).BuildUpdatedSnip();
+
+ Assert.Equal(snip.CliId, built.CliId);
+ }
}
}
diff --git a/tools/Snipdeck.Importer.Tests/CliSuggesterTests.cs b/tools/Snipdeck.Importer.Tests/CliSuggesterTests.cs
new file mode 100644
index 0000000..4df2c3b
--- /dev/null
+++ b/tools/Snipdeck.Importer.Tests/CliSuggesterTests.cs
@@ -0,0 +1,55 @@
+using Snipdeck.Importer.Translation;
+
+namespace Snipdeck.Importer.Tests
+{
+ public class CliSuggesterTests
+ {
+ [Theory]
+ [InlineData("mpt-app orders delete \"x.txt\"", "mpt-app")]
+ [InlineData("inv-app validate invoices", "inv-app")]
+ [InlineData("pip install --index-url https://x mpt-cli", "pip")]
+ public void First_token_is_the_suggested_cli(string command, string expected)
+ {
+ var result = CliSuggester.Suggest(command);
+ Assert.True(result.Confident);
+ Assert.Equal(expected, result.Name);
+ }
+
+ [Theory]
+ [InlineData("sudo systemctl restart x", "systemctl")]
+ [InlineData("npx create-react-app foo", "create-react-app")]
+ [InlineData("env FOO=bar mytool run", "mytool")]
+ public void Leading_wrappers_are_peeled(string command, string expected)
+ {
+ var result = CliSuggester.Suggest(command);
+ Assert.True(result.Confident);
+ Assert.Equal(expected, result.Name);
+ }
+
+ [Theory]
+ [InlineData("pip install x")]
+ [InlineData("python -m build")]
+ [InlineData("dotnet build")]
+ public void Language_runtimes_are_not_peeled(string command)
+ {
+ var first = command.Split(' ')[0];
+ var result = CliSuggester.Suggest(command);
+ Assert.Equal(first, result.Name);
+ }
+
+ [Fact]
+ public void Empty_command_is_not_confident()
+ {
+ var result = CliSuggester.Suggest(" ");
+ Assert.False(result.Confident);
+ Assert.Equal(string.Empty, result.Name);
+ }
+
+ [Fact]
+ public void A_parameterised_leading_token_is_not_confident()
+ {
+ var result = CliSuggester.Suggest("{tool} run");
+ Assert.False(result.Confident);
+ }
+ }
+}
diff --git a/tools/Snipdeck.Importer.Tests/Fixtures/snipcommand-sample.db b/tools/Snipdeck.Importer.Tests/Fixtures/snipcommand-sample.db
new file mode 100644
index 0000000..9180941
--- /dev/null
+++ b/tools/Snipdeck.Importer.Tests/Fixtures/snipcommand-sample.db
@@ -0,0 +1,51 @@
+{
+ "commands": [
+ {
+ "title": "Get latest mpt cli",
+ "command": "pip install --index-url https://example/simple/ mpt-cli==1.0.0",
+ "tags": "billing,adobe",
+ "description": "",
+ "isFavourite": false,
+ "isTrash": false,
+ "id": "Yyv0hhIcz"
+ },
+ {
+ "title": "Delete marketplace orders",
+ "tags": "mpt,orders",
+ "command": "mpt-app orders delete \"D:\\Working\\repairs\\ordersToDelete.txt\"",
+ "description": "",
+ "isFavourite": true,
+ "isTrash": false,
+ "id": "GlBRiwFzR",
+ "usageCount": 4,
+ "lastUsedAt": "2025-09-12T10:15:30Z"
+ },
+ {
+ "title": "Export subscriptions",
+ "command": "mpt-app adobe subscriptions export [sc_choice name=\"authId\" value=\"auth-adobe-ca-01,auth-adobe-de-01,auth-adobe-uk-01\" /]",
+ "tags": "adobe,subscriptions,export",
+ "description": "Exports subscriptions for an authorisation.",
+ "isFavourite": false,
+ "isTrash": false,
+ "id": "abc123"
+ },
+ {
+ "title": "Request 3YC",
+ "command": "mpt-app adobe agreements request-3yc [sc_variable name=\"Agreement ID\" value=\"\" /] [sc_choice name=\"authId\" value=\"auth-adobe-ca-01,auth-adobe-de-01\" /]",
+ "tags": "adobe,3yc",
+ "description": "",
+ "isFavourite": false,
+ "isTrash": false,
+ "id": "def456"
+ },
+ {
+ "title": "An old trashed snip",
+ "command": "mpt-app deprecated thing",
+ "tags": "",
+ "description": "",
+ "isFavourite": false,
+ "isTrash": true,
+ "id": "trash01"
+ }
+ ]
+}
diff --git a/tools/Snipdeck.Importer.Tests/ScMarkupTranslatorTests.cs b/tools/Snipdeck.Importer.Tests/ScMarkupTranslatorTests.cs
new file mode 100644
index 0000000..dde297c
--- /dev/null
+++ b/tools/Snipdeck.Importer.Tests/ScMarkupTranslatorTests.cs
@@ -0,0 +1,157 @@
+using Snipdeck.Core.Engine;
+using Snipdeck.Core.Models;
+using Snipdeck.Importer.Translation;
+
+namespace Snipdeck.Importer.Tests
+{
+ public class ScMarkupTranslatorTests
+ {
+ [Fact]
+ public void Plain_command_with_no_markup_is_unchanged_and_has_no_parameters()
+ {
+ var result = ScMarkupTranslator.Translate("mpt-app orders delete \"D:\\Working\\x.txt\"");
+
+ Assert.Equal("mpt-app orders delete \"D:\\Working\\x.txt\"", result.CommandTemplate);
+ Assert.Empty(result.Parameters);
+ }
+
+ [Fact]
+ public void Choice_markup_becomes_a_choice_parameter_with_options_and_first_default()
+ {
+ var result = ScMarkupTranslator.Translate(
+ "mpt-app export [sc_choice name=\"authId\" value=\"a-01,b-02,c-03\" /]");
+
+ Assert.Equal("mpt-app export {authId}", result.CommandTemplate);
+ var p = Assert.Single(result.Parameters);
+ Assert.Equal("authId", p.Name);
+ Assert.Equal(ParameterType.Choice, p.Type);
+ Assert.Equal(["a-01", "b-02", "c-03"], p.Options);
+ Assert.Equal("a-01", p.Default);
+ }
+
+ [Fact]
+ public void Variable_markup_becomes_a_text_parameter_with_its_value_as_default()
+ {
+ var result = ScMarkupTranslator.Translate(
+ "tool [sc_variable name=\"customerId\" value=\"1005\" /]");
+
+ Assert.Equal("tool {customerId}", result.CommandTemplate);
+ var p = Assert.Single(result.Parameters);
+ Assert.Equal("customerId", p.Name);
+ Assert.Equal(ParameterType.Text, p.Type);
+ Assert.Equal("1005", p.Default);
+ }
+
+ [Fact]
+ public void Empty_variable_value_yields_a_null_default()
+ {
+ var result = ScMarkupTranslator.Translate(
+ "tool [sc_variable name=\"Agreement ID\" value=\"\" /]");
+
+ var p = Assert.Single(result.Parameters);
+ Assert.Null(p.Default);
+ }
+
+ [Theory]
+ [InlineData("Agreement ID", "AgreementID")]
+ [InlineData("Order ID / Agreement ID", "OrderIDAgreementID")]
+ [InlineData("License Quantity", "LicenseQuantity")]
+ [InlineData("VIP Membership No", "VIPMembershipNo")]
+ public void Names_with_spaces_and_punctuation_are_slugified_to_legal_tokens(string name, string expectedToken)
+ {
+ var result = ScMarkupTranslator.Translate(
+ $"tool [sc_variable name=\"{name}\" value=\"\" /]");
+
+ Assert.Equal($"tool {{{expectedToken}}}", result.CommandTemplate);
+ Assert.Equal(expectedToken, Assert.Single(result.Parameters).Name);
+ }
+
+ [Fact]
+ public void Already_legal_names_are_preserved_verbatim()
+ {
+ var result = ScMarkupTranslator.Translate("tool [sc_variable name=\"authId\" value=\"x\" /]");
+ Assert.Equal("authId", Assert.Single(result.Parameters).Name);
+ }
+
+ [Fact]
+ public void A_name_repeated_in_one_command_maps_to_a_single_token_and_parameter()
+ {
+ var result = ScMarkupTranslator.Translate(
+ "tool [sc_variable name=\"Agreement ID\" value=\"\" /] then [sc_variable name=\"Agreement ID\" value=\"\" /]");
+
+ Assert.Equal("tool {AgreementID} then {AgreementID}", result.CommandTemplate);
+ Assert.Single(result.Parameters);
+ }
+
+ [Fact]
+ public void Distinct_names_that_slug_to_the_same_token_are_disambiguated()
+ {
+ // "Order ID" and "Order-ID" both slugify to "OrderID".
+ var result = ScMarkupTranslator.Translate(
+ "tool [sc_variable name=\"Order ID\" value=\"\" /] [sc_variable name=\"Order-ID\" value=\"\" /]");
+
+ Assert.Equal("tool {OrderID} {OrderID_2}", result.CommandTemplate);
+ Assert.Equal(2, result.Parameters.Count);
+ }
+
+ [Fact]
+ public void Multiple_mixed_markups_in_one_command_are_all_translated()
+ {
+ var result = ScMarkupTranslator.Translate(
+ "mpt-app adobe agreements request-3yc [sc_variable name=\"Agreement ID\" value=\"\" /] [sc_choice name=\"authId\" value=\"a,b\" /]");
+
+ Assert.Equal("mpt-app adobe agreements request-3yc {AgreementID} {authId}", result.CommandTemplate);
+ Assert.Equal(2, result.Parameters.Count);
+ }
+
+ [Fact]
+ public void Every_emitted_token_is_backed_by_a_parameter()
+ {
+ var result = ScMarkupTranslator.Translate(
+ "mpt-app x [sc_choice name=\"authId\" value=\"a,b\" /] [sc_variable name=\"Licensee IDs\" value=\"LCE-\" /]");
+
+ var defined = result.Parameters.Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
+ foreach (var token in SubstitutionEngine.ExtractTokens(result.CommandTemplate))
+ {
+ Assert.Contains(token, defined);
+ }
+ }
+
+ [Fact]
+ public void Malformed_markup_without_a_name_is_left_verbatim()
+ {
+ const string command = "tool [sc_variable value=\"x\" /]";
+ var result = ScMarkupTranslator.Translate(command);
+
+ Assert.Equal(command, result.CommandTemplate);
+ Assert.Empty(result.Parameters);
+ }
+
+ [Fact]
+ public void Non_self_closing_markup_is_left_verbatim()
+ {
+ const string command = "tool [sc_variable name=\"x\" value=\"1\"]";
+ var result = ScMarkupTranslator.Translate(command);
+
+ Assert.Equal(command, result.CommandTemplate);
+ Assert.Empty(result.Parameters);
+ }
+
+ [Fact]
+ public void Control_and_escape_characters_are_stripped_from_values()
+ {
+ var result = ScMarkupTranslator.Translate(
+ "tool [sc_variable name=\"x\" value=\"a[31mb\tc\" /]");
+
+ // ESC and tab removed; visible characters preserved.
+ Assert.Equal("a[31mbc", Assert.Single(result.Parameters).Default);
+ }
+
+ [Fact]
+ public void Null_or_empty_command_is_handled_gracefully()
+ {
+ Assert.Equal(string.Empty, ScMarkupTranslator.Translate(null).CommandTemplate);
+ Assert.Empty(ScMarkupTranslator.Translate("").Parameters);
+ }
+ }
+}
diff --git a/tools/Snipdeck.Importer.Tests/SnipCommandSourceTests.cs b/tools/Snipdeck.Importer.Tests/SnipCommandSourceTests.cs
new file mode 100644
index 0000000..f3099b1
--- /dev/null
+++ b/tools/Snipdeck.Importer.Tests/SnipCommandSourceTests.cs
@@ -0,0 +1,91 @@
+using Snipdeck.Core.Models;
+using Snipdeck.Importer.Sources;
+
+namespace Snipdeck.Importer.Tests
+{
+ public class SnipCommandSourceTests
+ {
+ private static readonly string _fixturePath =
+ Path.Combine(AppContext.BaseDirectory, "Fixtures", "snipcommand-sample.db");
+
+ [Fact]
+ public void Reads_the_sample_export_skipping_trashed_entries()
+ {
+ var candidates = new SnipCommandSource().Read(_fixturePath);
+
+ // 5 entries in the fixture, one of which is trashed.
+ Assert.Equal(4, candidates.Count);
+ Assert.DoesNotContain(candidates, c => c.Snip.Title == "An old trashed snip");
+ }
+
+ [Fact]
+ public void Suggests_the_cli_from_the_first_token()
+ {
+ var candidates = new SnipCommandSource().Read(_fixturePath);
+
+ var pip = candidates.Single(c => c.Snip.Title == "Get latest mpt cli");
+ Assert.Equal("pip", pip.SuggestedCliName);
+ Assert.True(pip.CliConfident);
+
+ var mpt = candidates.Single(c => c.Snip.Title == "Delete marketplace orders");
+ Assert.Equal("mpt-app", mpt.SuggestedCliName);
+ }
+
+ [Fact]
+ public void Carries_tags_favourite_usage_and_last_used()
+ {
+ var candidates = new SnipCommandSource().Read(_fixturePath);
+ var mpt = candidates.Single(c => c.Snip.Title == "Delete marketplace orders");
+
+ Assert.True(mpt.Snip.IsFavourite);
+ Assert.Equal(["mpt", "orders"], mpt.Snip.Tags);
+ Assert.Equal(4, mpt.Snip.UsageCount);
+ Assert.Equal(new DateTimeOffset(2025, 9, 12, 10, 15, 30, TimeSpan.Zero), mpt.Snip.LastUsedAt);
+ }
+
+ [Fact]
+ public void Empty_description_and_tags_become_null_and_empty()
+ {
+ var candidates = new SnipCommandSource().Read(_fixturePath);
+ var pip = candidates.Single(c => c.Snip.Title == "Get latest mpt cli");
+
+ Assert.Null(pip.Snip.Description);
+ Assert.Equal(["billing", "adobe"], pip.Snip.Tags);
+ }
+
+ [Fact]
+ public void Translates_markup_into_tokens_and_parameters()
+ {
+ var candidates = new SnipCommandSource().Read(_fixturePath);
+
+ var export = candidates.Single(c => c.Snip.Title == "Export subscriptions");
+ Assert.Equal("mpt-app adobe subscriptions export {authId}", export.Snip.CommandTemplate);
+ var choice = Assert.Single(export.Snip.Parameters);
+ Assert.Equal(ParameterType.Choice, choice.Type);
+ Assert.Equal(3, choice.Options.Count);
+
+ var threeYc = candidates.Single(c => c.Snip.Title == "Request 3YC");
+ Assert.Equal("mpt-app adobe agreements request-3yc {AgreementID} {authId}", threeYc.Snip.CommandTemplate);
+ Assert.Equal(2, threeYc.Snip.Parameters.Count);
+ }
+
+ [Fact]
+ public void Non_json_content_is_rejected_with_a_friendly_message()
+ {
+ var ex = Assert.Throws(() => SnipCommandSource.ReadFromText("SQLite format 3"));
+ Assert.Contains("SnipCommand", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Malformed_json_is_rejected_with_a_friendly_message()
+ {
+ Assert.Throws(() => SnipCommandSource.ReadFromText("{ \"commands\": [ "));
+ }
+
+ [Fact]
+ public void Missing_file_throws_file_not_found()
+ {
+ Assert.Throws(() => new SnipCommandSource().Read("/no/such/file.db"));
+ }
+ }
+}
diff --git a/tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj b/tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj
new file mode 100644
index 0000000..d188c32
--- /dev/null
+++ b/tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ net10.0
+ false
+
+ $(NoWarn);CA1707;CA1861;IDE0300;IDE0058
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs b/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs
new file mode 100644
index 0000000..1317e48
--- /dev/null
+++ b/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs
@@ -0,0 +1,200 @@
+using Snipdeck.Core.Models;
+using Snipdeck.Importer.Merge;
+using Snipdeck.Importer.Sources;
+
+namespace Snipdeck.Importer.Tests
+{
+ public class StoreMergerTests
+ {
+ private static readonly MergeOptions _defaults = new(ForceCli: null, Into: null, AllowDuplicates: false);
+
+ private static SnippetCandidate Candidate(string cli, string title, string template, bool confident = true)
+ {
+ return new SnippetCandidate(cli, confident, new Snip { Title = title, CommandTemplate = template });
+ }
+
+ [Fact]
+ public void New_cli_is_created_on_demand_and_snips_are_attached_to_it()
+ {
+ var doc = new SnipStoreDocument();
+ var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults);
+
+ Assert.Equal(["mpt-app"], plan.ClisToCreate);
+ Assert.Equal(1, plan.ImportCount);
+
+ StoreMerger.Apply(doc, plan);
+
+ var cli = Assert.Single(doc.Clis);
+ Assert.Equal("mpt-app", cli.Name);
+ Assert.NotEqual(Guid.Empty, cli.Id);
+ Assert.Equal(cli.Id, Assert.Single(doc.Snips).CliId);
+ }
+
+ [Fact]
+ public void Existing_cli_is_reused_case_insensitively()
+ {
+ var existing = new Cli { Name = "MPT-App" };
+ var doc = new SnipStoreDocument { Clis = { existing } };
+
+ var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults);
+ Assert.Empty(plan.ClisToCreate);
+
+ StoreMerger.Apply(doc, plan);
+
+ Assert.Single(doc.Clis);
+ Assert.Equal(existing.Id, Assert.Single(doc.Snips).CliId);
+ }
+
+ [Fact]
+ public void Multiple_snips_for_the_same_new_cli_create_it_once()
+ {
+ var doc = new SnipStoreDocument();
+ var plan = StoreMerger.Plan(
+ doc,
+ [
+ Candidate("mpt-app", "A", "mpt-app a"),
+ Candidate("mpt-app", "B", "mpt-app b"),
+ ],
+ _defaults);
+
+ Assert.Equal(["mpt-app"], plan.ClisToCreate);
+
+ StoreMerger.Apply(doc, plan);
+
+ Assert.Single(doc.Clis);
+ Assert.Equal(2, doc.Snips.Count);
+ Assert.All(doc.Snips, s => Assert.Equal(doc.Clis[0].Id, s.CliId));
+ }
+
+ [Fact]
+ public void Duplicate_against_existing_store_is_skipped_by_default()
+ {
+ var cli = new Cli { Name = "mpt-app" };
+ var doc = new SnipStoreDocument
+ {
+ Clis = { cli },
+ Snips = { new Snip { CliId = cli.Id, Title = "Delete", CommandTemplate = "mpt-app orders delete" } },
+ };
+
+ var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults);
+
+ Assert.Equal(0, plan.ImportCount);
+ Assert.Equal(1, plan.SkipCount);
+ Assert.Empty(plan.ClisToCreate);
+
+ StoreMerger.Apply(doc, plan);
+ Assert.Single(doc.Snips);
+ }
+
+ [Fact]
+ public void A_trashed_existing_snip_does_not_block_the_import()
+ {
+ var cli = new Cli { Name = "mpt-app" };
+ var doc = new SnipStoreDocument
+ {
+ Clis = { cli },
+ Snips = { new Snip { CliId = cli.Id, Title = "Delete", CommandTemplate = "mpt-app orders delete", IsTrash = true } },
+ };
+
+ var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults);
+ Assert.Equal(1, plan.ImportCount);
+ }
+
+ [Fact]
+ public void Same_title_and_command_under_a_different_cli_is_not_a_duplicate()
+ {
+ var other = new Cli { Name = "other-app" };
+ var doc = new SnipStoreDocument
+ {
+ Clis = { other },
+ Snips = { new Snip { CliId = other.Id, Title = "Delete", CommandTemplate = "mpt-app orders delete" } },
+ };
+
+ // Same title+command, but it auto-suggests CLI "mpt-app" — a different CLI from the existing snip.
+ var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults);
+
+ Assert.Equal(1, plan.ImportCount);
+ Assert.Equal(["mpt-app"], plan.ClisToCreate);
+ }
+
+ [Fact]
+ public void AllowDuplicates_imports_even_when_a_match_exists()
+ {
+ var cli = new Cli { Name = "mpt-app" };
+ var doc = new SnipStoreDocument
+ {
+ Clis = { cli },
+ Snips = { new Snip { CliId = cli.Id, Title = "Delete", CommandTemplate = "mpt-app orders delete" } },
+ };
+
+ var options = _defaults with { AllowDuplicates = true };
+ var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], options);
+
+ Assert.Equal(1, plan.ImportCount);
+ StoreMerger.Apply(doc, plan);
+ Assert.Equal(2, doc.Snips.Count);
+ }
+
+ [Fact]
+ public void Identical_candidates_within_one_batch_are_deduped()
+ {
+ var doc = new SnipStoreDocument();
+ var plan = StoreMerger.Plan(
+ doc,
+ [
+ Candidate("mpt-app", "Delete", "mpt-app orders delete"),
+ Candidate("mpt-app", "Delete", "mpt-app orders delete"),
+ ],
+ _defaults);
+
+ Assert.Equal(1, plan.ImportCount);
+ Assert.Equal(1, plan.SkipCount);
+ }
+
+ [Fact]
+ public void ForceCli_overrides_the_suggested_cli_for_every_candidate()
+ {
+ var doc = new SnipStoreDocument();
+ var options = _defaults with { ForceCli = "bucket" };
+ var plan = StoreMerger.Plan(
+ doc,
+ [
+ Candidate("mpt-app", "A", "mpt-app a"),
+ Candidate("inv-app", "B", "inv-app b"),
+ ],
+ options);
+
+ Assert.All(plan.Items, i => Assert.Equal("bucket", i.TargetCliName));
+ Assert.Equal(["bucket"], plan.ClisToCreate);
+ }
+
+ [Fact]
+ public void Into_is_used_only_for_unconfident_suggestions()
+ {
+ var doc = new SnipStoreDocument();
+ var options = _defaults with { Into = "fallback" };
+ var plan = StoreMerger.Plan(
+ doc,
+ [
+ Candidate("mpt-app", "A", "mpt-app a", confident: true),
+ new SnippetCandidate(string.Empty, false, new Snip { Title = "B", CommandTemplate = "{x} b" }),
+ ],
+ options);
+
+ Assert.Equal("mpt-app", plan.Items[0].TargetCliName);
+ Assert.Equal("fallback", plan.Items[1].TargetCliName);
+ }
+
+ [Fact]
+ public void Unconfident_with_no_into_falls_back_to_a_generic_bucket()
+ {
+ var doc = new SnipStoreDocument();
+ var plan = StoreMerger.Plan(
+ doc,
+ [new SnippetCandidate(string.Empty, false, new Snip { Title = "B", CommandTemplate = "{x} b" })],
+ _defaults);
+
+ Assert.Equal("imported", plan.Items[0].TargetCliName);
+ }
+ }
+}
diff --git a/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs b/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs
new file mode 100644
index 0000000..6281cc6
--- /dev/null
+++ b/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs
@@ -0,0 +1,37 @@
+using System.ComponentModel;
+
+using Spectre.Console.Cli;
+
+namespace Snipdeck.Importer.Commands
+{
+ ///
+ /// Options shared by every import subcommand. Defaults to a safe dry-run; only --write
+ /// touches the store.
+ ///
+ public class ImportCommandSettings : CommandSettings
+ {
+ [CommandArgument(0, "")]
+ [Description("Path to the source export to import.")]
+ public string Path { get; init; } = string.Empty;
+
+ [CommandOption("--store ")]
+ [Description("Target Snipdeck store file. Defaults to the desktop app's store.")]
+ public string? Store { get; init; }
+
+ [CommandOption("--write")]
+ [Description("Apply the changes. Without this flag the importer only previews (dry-run).")]
+ public bool Write { get; init; }
+
+ [CommandOption("--cli ")]
+ [Description("Force every imported snip into this CLI, overriding auto-detection.")]
+ public string? Cli { get; init; }
+
+ [CommandOption("--into ")]
+ [Description("Fallback CLI for snips whose CLI could not be confidently auto-detected.")]
+ public string? Into { get; init; }
+
+ [CommandOption("--allow-duplicates")]
+ [Description("Import snips even if one with the same title and command already exists.")]
+ public bool AllowDuplicates { get; init; }
+ }
+}
diff --git a/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs b/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs
new file mode 100644
index 0000000..e44dbea
--- /dev/null
+++ b/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs
@@ -0,0 +1,105 @@
+using Snipdeck.Core.Services;
+using Snipdeck.Importer.Merge;
+using Snipdeck.Importer.Output;
+using Snipdeck.Importer.Sources;
+
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Snipdeck.Importer.Commands
+{
+ ///
+ /// snipdeck-importer snipcommand <path> — imports a SnipCommand export.
+ /// Previews by default; --write backs the store up, merges, and saves.
+ ///
+ public sealed class SnipCommandImportCommand : AsyncCommand
+ {
+ protected override async Task ExecuteAsync(
+ CommandContext context,
+ ImportCommandSettings settings,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+
+ var source = new SnipCommandSource();
+ IReadOnlyList candidates;
+ try
+ {
+ candidates = source.Read(settings.Path);
+ }
+ catch (Exception ex) when (ex is FileNotFoundException or InvalidDataException or UnauthorizedAccessException or IOException)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[red]error:[/] {ex.Message}");
+ return 1;
+ }
+
+ var targets = await ResolveTargetsAsync(settings.Store).ConfigureAwait(false);
+
+ var store = new JsonSnipStore(targets.StorePath);
+ var document = await store.LoadAsync(cancellationToken).ConfigureAwait(false);
+
+ var options = new MergeOptions(settings.Cli, settings.Into, settings.AllowDuplicates);
+ var plan = StoreMerger.Plan(document, candidates, options);
+
+ DryRunRenderer.Render(source.DisplayName, targets.StorePath, plan, settings.Write);
+
+ if (!settings.Write)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[grey]This was a dry-run. Re-run with [bold]--write[/] to apply.[/]");
+ return 0;
+ }
+
+ if (plan.ImportCount == 0)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("Nothing to import. The store was not modified.");
+ return 0;
+ }
+
+ var backup = new BackupService(targets.StorePath, targets.BackupDirectory, new SystemClock(), targets.Retention);
+ var backupInfo = await backup.CreateBackupAsync(cancellationToken).ConfigureAwait(false);
+
+ StoreMerger.Apply(document, plan);
+ await store.SaveAsync(document, cancellationToken).ConfigureAwait(false);
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLineInterpolated(
+ $"[green]Imported {plan.ImportCount} snip(s)[/] into {targets.StorePath}.");
+ if (backupInfo is not null)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[grey]Backed up the previous store to {backupInfo.FilePath}[/]");
+ }
+
+ AnsiConsole.MarkupLine("[yellow]If Snipdeck is running, restart it to see the imported Snips.[/]");
+ return 0;
+ }
+
+ private static async Task ResolveTargetsAsync(string? explicitStore)
+ {
+ // An explicit --store keeps its backups beside it and uses the default retention,
+ // so importing into a throwaway store never touches the desktop app's backup folder.
+ if (!string.IsNullOrWhiteSpace(explicitStore))
+ {
+ var storePath = Path.GetFullPath(explicitStore);
+ var directory = Path.GetDirectoryName(storePath) ?? Directory.GetCurrentDirectory();
+ var backupDirectory = Path.Combine(directory, DefaultPaths.BackupsDirectoryName);
+ return new ImportTargets(storePath, backupDirectory, BackupService.DefaultRetention);
+ }
+
+ // Otherwise mirror exactly what the desktop app resolves from its config.
+ var settingsStore = new JsonSettingsStore(DefaultPaths.SettingsFilePath);
+ var config = await settingsStore.LoadAsync().ConfigureAwait(false);
+
+ // Clamp like the desktop app's lazy retention provider does — a corrupt config
+ // (e.g. BackupRetention = 0) must never crash the importer's fixed-retention BackupService.
+ var retention = Math.Max(1, config.BackupRetention);
+ return new ImportTargets(
+ DefaultPaths.ResolveStoreFilePath(config.StoragePath),
+ config.BackupDirectory ?? DefaultPaths.DefaultBackupDirectory,
+ retention);
+ }
+
+ private sealed record ImportTargets(string StorePath, string BackupDirectory, int Retention);
+ }
+}
diff --git a/tools/Snipdeck.Importer/Merge/StoreMerger.cs b/tools/Snipdeck.Importer/Merge/StoreMerger.cs
new file mode 100644
index 0000000..9aed541
--- /dev/null
+++ b/tools/Snipdeck.Importer/Merge/StoreMerger.cs
@@ -0,0 +1,145 @@
+using Snipdeck.Core.Models;
+using Snipdeck.Importer.Sources;
+
+namespace Snipdeck.Importer.Merge
+{
+ ///
+ /// Plans and applies a merge of imported snippet candidates into an existing store document.
+ ///
+ /// is pure — it decides, per candidate, the target CLI and whether it is a
+ /// duplicate to skip, and lists the CLIs that would be created. mutates the
+ /// document: it creates missing CLIs, mints fresh identifiers, and appends the imported snips.
+ ///
+ ///
+ public static class StoreMerger
+ {
+ public static MergePlan Plan(
+ SnipStoreDocument target,
+ IReadOnlyList candidates,
+ MergeOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(candidates);
+ ArgumentNullException.ThrowIfNull(options);
+
+ // De-duplication is scoped to the CLI a snip lands in: CLI is Snipdeck's organising
+ // axis, so the same (Title, CommandTemplate) under two different CLIs is legitimate.
+ var cliNameById = target.Clis.ToDictionary(c => c.Id, c => c.Name);
+ var existing = new HashSet<(string, string, string)>();
+ foreach (var snip in target.Snips)
+ {
+ if (!snip.IsTrash)
+ {
+ var owningCli = cliNameById.TryGetValue(snip.CliId, out var name) ? name : string.Empty;
+ _ = existing.Add(DedupeKey(owningCli, snip.Title, snip.CommandTemplate));
+ }
+ }
+
+ var existingCliNames = new HashSet(
+ target.Clis.Select(c => c.Name),
+ StringComparer.OrdinalIgnoreCase);
+
+ var items = new List(candidates.Count);
+ var clisToCreate = new List();
+ var seenNewClis = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var plannedKeys = new HashSet<(string, string, string)>();
+
+ foreach (var candidate in candidates)
+ {
+ var cliName = ResolveCliName(candidate, options);
+ var key = DedupeKey(cliName, candidate.Snip.Title, candidate.Snip.CommandTemplate);
+
+ var isDuplicate = !options.AllowDuplicates
+ && (existing.Contains(key) || plannedKeys.Contains(key));
+
+ items.Add(new MergePlanItem(candidate, cliName, isDuplicate));
+
+ if (isDuplicate)
+ {
+ continue;
+ }
+
+ _ = plannedKeys.Add(key);
+
+ if (!existingCliNames.Contains(cliName) && seenNewClis.Add(cliName))
+ {
+ clisToCreate.Add(cliName);
+ }
+ }
+
+ return new MergePlan(items, clisToCreate);
+ }
+
+ public static void Apply(SnipStoreDocument target, MergePlan plan)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(plan);
+
+ // Case-insensitive lookup of CLI name -> Cli, seeded with what's already there.
+ var clisByName = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var cli in target.Clis)
+ {
+ _ = clisByName.TryAdd(cli.Name, cli);
+ }
+
+ foreach (var item in plan.Items)
+ {
+ if (item.IsDuplicateSkip)
+ {
+ continue;
+ }
+
+ if (!clisByName.TryGetValue(item.TargetCliName, out var cli))
+ {
+ cli = new Cli { Name = item.TargetCliName };
+ target.Clis.Add(cli);
+ clisByName[item.TargetCliName] = cli;
+ }
+
+ // The candidate already carries a fresh GUID (the source never reuses SnipCommand
+ // ids); just point it at the resolved CLI and append.
+ var snip = item.Candidate.Snip;
+ snip.CliId = cli.Id;
+ target.Snips.Add(snip);
+ }
+ }
+
+ private static (string, string, string) DedupeKey(string cliName, string title, string commandTemplate)
+ {
+ // CLI name matches case-insensitively (Cli reuse is case-insensitive); title/command are exact.
+ return (cliName.ToLowerInvariant(), title, commandTemplate);
+ }
+
+ private static string ResolveCliName(SnippetCandidate candidate, MergeOptions options)
+ {
+ if (!string.IsNullOrWhiteSpace(options.ForceCli))
+ {
+ return options.ForceCli.Trim();
+ }
+
+ var confidentName = candidate.CliConfident && !string.IsNullOrWhiteSpace(candidate.SuggestedCliName)
+ ? candidate.SuggestedCliName
+ : null;
+ var into = string.IsNullOrWhiteSpace(options.Into) ? null : options.Into.Trim();
+ var bestEffort = string.IsNullOrWhiteSpace(candidate.SuggestedCliName) ? null : candidate.SuggestedCliName;
+
+ // Precedence: a confident auto-suggestion, then the --into fallback, then any suggestion,
+ // and finally a generic bucket so nothing is ever left without a home.
+ return confidentName ?? into ?? bestEffort ?? "imported";
+ }
+ }
+
+ /// Knobs that shape a merge, mapped from the CLI options.
+ public sealed record MergeOptions(string? ForceCli, string? Into, bool AllowDuplicates);
+
+ /// One candidate's planned outcome.
+ public sealed record MergePlanItem(SnippetCandidate Candidate, string TargetCliName, bool IsDuplicateSkip);
+
+ /// The full plan: per-candidate decisions and the CLIs that would be created.
+ public sealed record MergePlan(IReadOnlyList Items, IReadOnlyList ClisToCreate)
+ {
+ public int ImportCount => Items.Count(i => !i.IsDuplicateSkip);
+
+ public int SkipCount => Items.Count(i => i.IsDuplicateSkip);
+ }
+}
diff --git a/tools/Snipdeck.Importer/Output/DryRunRenderer.cs b/tools/Snipdeck.Importer/Output/DryRunRenderer.cs
new file mode 100644
index 0000000..b3c365b
--- /dev/null
+++ b/tools/Snipdeck.Importer/Output/DryRunRenderer.cs
@@ -0,0 +1,77 @@
+using Snipdeck.Importer.Merge;
+
+using Spectre.Console;
+
+namespace Snipdeck.Importer.Output
+{
+ ///
+ /// Renders a merge plan as Spectre tables grouped by target CLI. All cell content comes from
+ /// untrusted input, so every value is escaped before rendering.
+ ///
+ public static class DryRunRenderer
+ {
+ public static void Render(string sourceName, string storePath, MergePlan plan, bool willWrite)
+ {
+ var mode = willWrite ? "[yellow]WRITE[/]" : "[green]DRY-RUN[/]";
+ AnsiConsole.MarkupLineInterpolated(
+ $"Importing from {sourceName} into {storePath}");
+ AnsiConsole.MarkupLine($"Mode: {mode}");
+ AnsiConsole.WriteLine();
+
+ var byCli = plan.Items
+ .GroupBy(i => i.TargetCliName, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase);
+
+ var newClis = new HashSet(plan.ClisToCreate, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var group in byCli)
+ {
+ var heading = newClis.Contains(group.Key)
+ ? $"{Escape(group.Key)} [grey](new CLI)[/]"
+ : Escape(group.Key);
+
+ var table = new Table()
+ .Title(heading)
+ .Border(TableBorder.Rounded)
+ .AddColumn("Title")
+ .AddColumn("Command template")
+ .AddColumn("Params")
+ .AddColumn("Status");
+
+ foreach (var item in group)
+ {
+ var status = item.IsDuplicateSkip
+ ? "[grey]skip (duplicate)[/]"
+ : "[green]import[/]";
+ var lowConfidence = !item.Candidate.CliConfident && !item.IsDuplicateSkip
+ ? " [yellow](CLI guessed)[/]"
+ : string.Empty;
+
+ _ = table.AddRow(
+ Escape(item.Candidate.Snip.Title),
+ Escape(Truncate(item.Candidate.Snip.CommandTemplate, 70)),
+ item.Candidate.Snip.Parameters.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ status + lowConfidence);
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLineInterpolated(
+ $"CLIs to create: {plan.ClisToCreate.Count} Snips to import: {plan.ImportCount} Skipped (duplicates): {plan.SkipCount}");
+ }
+
+ private static string Escape(string value)
+ {
+ return Markup.Escape(value ?? string.Empty);
+ }
+
+ private static string Truncate(string value, int max)
+ {
+ return string.IsNullOrEmpty(value) || value.Length <= max
+ ? value ?? string.Empty
+ : value[..(max - 1)] + "…";
+ }
+ }
+}
diff --git a/tools/Snipdeck.Importer/Program.cs b/tools/Snipdeck.Importer/Program.cs
new file mode 100644
index 0000000..3c00dce
--- /dev/null
+++ b/tools/Snipdeck.Importer/Program.cs
@@ -0,0 +1,17 @@
+using Snipdeck.Importer.Commands;
+
+using Spectre.Console.Cli;
+
+var app = new CommandApp();
+app.Configure(config =>
+{
+ _ = config.SetApplicationName("snipdeck-importer");
+
+ _ = config.AddCommand("snipcommand")
+ .WithDescription("Import command snippets from a SnipCommand export.")
+ .WithExample("snipcommand", "snipcommand.db")
+ .WithExample("snipcommand", "snipcommand.db", "--write")
+ .WithExample("snipcommand", "snipcommand.db", "--store", "store.json", "--cli", "mpt-app");
+});
+
+return await app.RunAsync(args);
diff --git a/tools/Snipdeck.Importer/README.md b/tools/Snipdeck.Importer/README.md
new file mode 100644
index 0000000..c21ff1d
--- /dev/null
+++ b/tools/Snipdeck.Importer/README.md
@@ -0,0 +1,82 @@
+# snipdeck-importer
+
+A small cross-platform console tool that imports command snippets from other tools
+into a [Snipdeck](../../README.md) store. The first supported source is
+**SnipCommand**; the design leaves room for more sources as subcommands later.
+
+It is shipped and versioned independently of the desktop app (it is a .NET tool),
+and targets `net10.0`, so it builds and runs on Windows, Linux and macOS — handy
+for bulk-prepping an import on a non-Windows box before opening Snipdeck.
+
+## Install
+
+```bash
+dotnet tool install -g Snipdeck.Importer
+```
+
+Or run it straight from the repo without installing:
+
+```bash
+dotnet run --project tools/Snipdeck.Importer -- snipcommand
+```
+
+## Usage
+
+```
+snipdeck-importer snipcommand [options]
+```
+
+`` is a SnipCommand export. Despite SnipCommand's `.db` extension, the export
+is JSON — point the importer straight at it.
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `--store ` | the desktop app's store | Target Snipdeck store file. |
+| `--write` | off | Apply the changes. Without it, the importer only previews (dry-run). |
+| `--cli ` | — | Force every imported snip into this CLI, overriding auto-detection. |
+| `--into ` | — | Fallback CLI for snips whose CLI could not be confidently auto-detected. |
+| `--allow-duplicates` | off | Import snips even if one with the same title and command already exists. |
+
+### Dry-run first
+
+By default the importer parses the source, prints the planned additions grouped by
+the CLI each snip would land in, and exits **without touching the store**:
+
+```bash
+snipdeck-importer snipcommand snipcommand.db
+```
+
+Add `--write` once the preview looks right:
+
+```bash
+snipdeck-importer snipcommand snipcommand.db --write
+```
+
+## What it does
+
+- **Groups by CLI.** SnipCommand stores a flat list; Snipdeck organises snips under
+ a CLI. The CLI is auto-suggested from the first whitespace-delimited token of each
+ command (`mpt-app orders list` → `mpt-app`), peeling off launcher wrappers such as
+ `sudo` / `npx`. Use `--cli` to force one CLI for everything, or `--into` as a
+ fallback for commands the importer couldn't classify confidently.
+- **Translates parameters.** SnipCommand's inline `[sc_choice …]` and
+ `[sc_variable …]` markup becomes Snipdeck `{token}` placeholders plus a structured
+ parameter list (Choice with options, or Text), with a sensible default carried
+ across. Variable names containing spaces or punctuation are slugified into legal
+ token names.
+- **Carries metadata.** Title, description, tags and favourite flag come across;
+ usage counts and last-used timestamps are preserved when present. Trashed entries
+ are skipped.
+- **Merges safely.** With `--write` the importer backs the existing store up first
+ (honouring the desktop app's retention policy), mints fresh identifiers, creates
+ any missing CLIs on demand, and skips snips that already exist by
+ `(title, command)` unless `--allow-duplicates` is given. The atomic store write
+ means a running desktop instance can't be corrupted — but it won't *see* the
+ changes until it next reloads, so **restart Snipdeck after importing**.
+
+## Building and testing
+
+```bash
+dotnet build tools/Snipdeck.Importer
+dotnet test tools/Snipdeck.Importer.Tests
+```
diff --git a/tools/Snipdeck.Importer/Snipdeck.Importer.csproj b/tools/Snipdeck.Importer/Snipdeck.Importer.csproj
new file mode 100644
index 0000000..8f84bf2
--- /dev/null
+++ b/tools/Snipdeck.Importer/Snipdeck.Importer.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Exe
+ net10.0
+ Snipdeck.Importer
+
+
+ true
+ snipdeck-importer
+ Snipdeck.Importer
+ Imports command snippets from SnipCommand (and other sources) into a Snipdeck store.
+
+
+ $(NoWarn);CS1591;CA1305
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/Snipdeck.Importer/Sources/ISnippetSource.cs b/tools/Snipdeck.Importer/Sources/ISnippetSource.cs
new file mode 100644
index 0000000..c921fb5
--- /dev/null
+++ b/tools/Snipdeck.Importer/Sources/ISnippetSource.cs
@@ -0,0 +1,24 @@
+using Snipdeck.Core.Models;
+
+namespace Snipdeck.Importer.Sources
+{
+ ///
+ /// Reads snippet candidates from a source export. Each adapter (SnipCommand today, others later)
+ /// isolates all format-specific parsing behind this interface and yields format-agnostic
+ /// s for the merger to consume.
+ ///
+ public interface ISnippetSource
+ {
+ /// The human-friendly source name, used in messages (e.g. "SnipCommand").
+ string DisplayName { get; }
+
+ /// Reads the file at and returns the candidates it contains.
+ IReadOnlyList Read(string path);
+ }
+
+ ///
+ /// A snip ready to import, plus the source's suggestion for which CLI it belongs to and whether
+ /// that suggestion is trustworthy. The merger resolves the final CLI from this and the CLI options.
+ ///
+ public sealed record SnippetCandidate(string SuggestedCliName, bool CliConfident, Snip Snip);
+}
diff --git a/tools/Snipdeck.Importer/Sources/SnipCommandJsonModels.cs b/tools/Snipdeck.Importer/Sources/SnipCommandJsonModels.cs
new file mode 100644
index 0000000..62471ea
--- /dev/null
+++ b/tools/Snipdeck.Importer/Sources/SnipCommandJsonModels.cs
@@ -0,0 +1,33 @@
+namespace Snipdeck.Importer.Sources
+{
+ ///
+ /// Mirrors a SnipCommand export document. Despite the .db extension SnipCommand uses,
+ /// the export is pretty-printed JSON of the shape { "commands": [ … ] }.
+ /// Deserialised case-insensitively, so the camelCase JSON keys map onto these members.
+ ///
+ internal sealed class SnipCommandExport
+ {
+ public List Commands { get; set; } = [];
+ }
+
+ /// One SnipCommand entry. The opaque id is intentionally not modelled — it is discarded.
+ internal sealed class SnipCommandEntry
+ {
+ public string? Title { get; set; }
+
+ public string? Command { get; set; }
+
+ /// Comma-separated tag list (a single string, not an array).
+ public string? Tags { get; set; }
+
+ public string? Description { get; set; }
+
+ public bool IsFavourite { get; set; }
+
+ public bool IsTrash { get; set; }
+
+ public int? UsageCount { get; set; }
+
+ public DateTimeOffset? LastUsedAt { get; set; }
+ }
+}
diff --git a/tools/Snipdeck.Importer/Sources/SnipCommandSource.cs b/tools/Snipdeck.Importer/Sources/SnipCommandSource.cs
new file mode 100644
index 0000000..091fed2
--- /dev/null
+++ b/tools/Snipdeck.Importer/Sources/SnipCommandSource.cs
@@ -0,0 +1,97 @@
+using System.Text.Json;
+
+using Snipdeck.Core.Engine;
+using Snipdeck.Core.Models;
+using Snipdeck.Importer.Translation;
+
+namespace Snipdeck.Importer.Sources
+{
+ ///
+ /// Reads a SnipCommand JSON export and yields import candidates, translating inline
+ /// sc_* markup into structured parameters and suggesting a CLI per command.
+ /// All SnipCommand-specific knowledge lives here.
+ ///
+ public sealed class SnipCommandSource : ISnippetSource
+ {
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ AllowTrailingCommas = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ };
+
+ public string DisplayName => "SnipCommand";
+
+ public IReadOnlyList Read(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"SnipCommand export not found: {path}", path);
+ }
+
+ var text = File.ReadAllText(path);
+ return ReadFromText(text);
+ }
+
+ /// Parses export content already loaded into memory. Exposed for testing.
+ public static IReadOnlyList ReadFromText(string text)
+ {
+ // Sniff the format: SnipCommand exports are a JSON object, regardless of the .db extension.
+ var trimmed = text.AsSpan().TrimStart();
+ if (trimmed.IsEmpty || trimmed[0] != '{')
+ {
+ throw new InvalidDataException(
+ "The file does not look like a SnipCommand JSON export (expected a JSON object starting with '{').");
+ }
+
+ SnipCommandExport? export;
+ try
+ {
+ export = JsonSerializer.Deserialize(text, _jsonOptions);
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidDataException($"The SnipCommand export could not be parsed: {ex.Message}", ex);
+ }
+
+ if (export?.Commands is not { Count: > 0 } commands)
+ {
+ return [];
+ }
+
+ var candidates = new List(commands.Count);
+ foreach (var entry in commands)
+ {
+ if (entry is null || entry.IsTrash)
+ {
+ continue;
+ }
+
+ candidates.Add(ToCandidate(entry));
+ }
+
+ return candidates;
+ }
+
+ private static SnippetCandidate ToCandidate(SnipCommandEntry entry)
+ {
+ var translation = ScMarkupTranslator.Translate(entry.Command);
+ var suggestion = CliSuggester.Suggest(translation.CommandTemplate);
+
+ var snip = new Snip
+ {
+ Title = (entry.Title ?? string.Empty).Trim(),
+ CommandTemplate = translation.CommandTemplate,
+ Description = string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description.Trim(),
+ Tags = TagParser.Parse(entry.Tags),
+ Parameters = [.. translation.Parameters],
+ IsFavourite = entry.IsFavourite,
+ UsageCount = entry.UsageCount is > 0 ? entry.UsageCount.Value : 0,
+ LastUsedAt = entry.LastUsedAt,
+ };
+
+ return new SnippetCandidate(suggestion.Name, suggestion.Confident, snip);
+ }
+ }
+}
diff --git a/tools/Snipdeck.Importer/Translation/CliSuggester.cs b/tools/Snipdeck.Importer/Translation/CliSuggester.cs
new file mode 100644
index 0000000..d25d46c
--- /dev/null
+++ b/tools/Snipdeck.Importer/Translation/CliSuggester.cs
@@ -0,0 +1,61 @@
+namespace Snipdeck.Importer.Translation
+{
+ ///
+ /// Suggests the owning CLI for a command by taking its first whitespace-delimited token,
+ /// peeling off well-known process wrappers (sudo, npx, …) so the suggestion is
+ /// the real tool rather than the launcher.
+ ///
+ /// Language runtimes such as pip, python and dotnet are deliberately
+ /// not peeled: in practice those are the CLI the user organises around.
+ ///
+ ///
+ public static class CliSuggester
+ {
+ // Pure launchers: the interesting command is whatever they invoke.
+ private static readonly HashSet _wrappers = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "sudo", "doas", "env", "npx", "pnpx", "bunx", "time", "nice", "nohup", "command", "exec",
+ };
+
+ public static CliSuggestion Suggest(string? commandTemplate)
+ {
+ if (string.IsNullOrWhiteSpace(commandTemplate))
+ {
+ return new CliSuggestion(string.Empty, Confident: false);
+ }
+
+ var tokens = commandTemplate.Split(
+ (char[]?)null,
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ var index = 0;
+
+ // Peel any leading wrappers, plus their leading -flags (e.g. `env FOO=bar tool`).
+ while (index < tokens.Length && IsWrapperOrFlag(tokens[index]))
+ {
+ index++;
+ }
+
+ if (index >= tokens.Length)
+ {
+ return new CliSuggestion(string.Empty, Confident: false);
+ }
+
+ var candidate = tokens[index];
+
+ // A leading placeholder (the command itself is parameterised) can't be a confident name.
+ return candidate.Contains('{')
+ ? new CliSuggestion(string.Empty, Confident: false)
+ : new CliSuggestion(candidate, Confident: true);
+ }
+
+ private static bool IsWrapperOrFlag(string token)
+ {
+ // Option flags and env-style assignments (FOO=bar) belong to the wrapper, not the tool.
+ return token.StartsWith('-') || token.Contains('=') || _wrappers.Contains(token);
+ }
+ }
+
+ /// A suggested CLI name and whether the suggestion is trustworthy.
+ public sealed record CliSuggestion(string Name, bool Confident);
+}
diff --git a/tools/Snipdeck.Importer/Translation/ScMarkupTranslator.cs b/tools/Snipdeck.Importer/Translation/ScMarkupTranslator.cs
new file mode 100644
index 0000000..65848fb
--- /dev/null
+++ b/tools/Snipdeck.Importer/Translation/ScMarkupTranslator.cs
@@ -0,0 +1,218 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+using Snipdeck.Core.Models;
+
+namespace Snipdeck.Importer.Translation
+{
+ ///
+ /// Translates SnipCommand inline markup into Snipdeck's {token} placeholders plus a
+ /// structured list.
+ ///
+ /// Two self-closing forms are recognised:
+ /// [sc_choice name="x" value="a,b,c" /] (CSV options, first is the default) and
+ /// [sc_variable name="x" value="default" /] (an empty value means no default).
+ ///
+ ///
+ /// The translator is pure and defensive: input is treated as untrusted, names are slugified
+ /// into legal token identifiers (with collisions disambiguated), control/escape characters are
+ /// stripped from values, lengths are capped, and any markup it cannot parse is left verbatim so
+ /// the command never gets corrupted.
+ ///
+ ///
+ public static partial class ScMarkupTranslator
+ {
+ private const int _maxNameLength = 100;
+ private const int _maxValueLength = 4000;
+ private const int _maxOptions = 100;
+
+ // A self-closing [sc_choice ...] / [sc_variable ...] tag. Attribute values are
+ // double-quoted and may contain anything except a quote (so backslashes, spaces,
+ // commas and ']' inside a value are fine). Non-self-closing or malformed tags simply
+ // don't match and are left untouched.
+ [GeneratedRegex(
+ """\[sc_(?choice|variable)(?(?:\s+[A-Za-z][A-Za-z0-9]*\s*=\s*"[^"]*")*)\s*/\]""",
+ RegexOptions.CultureInvariant)]
+ private static partial Regex TagRegex();
+
+ [GeneratedRegex(
+ "(?[A-Za-z][A-Za-z0-9]*)\\s*=\\s*\"(?[^\"]*)\"",
+ RegexOptions.CultureInvariant)]
+ private static partial Regex AttrRegex();
+
+ public static ScTranslation Translate(string? command)
+ {
+ if (string.IsNullOrEmpty(command))
+ {
+ return new ScTranslation(command ?? string.Empty, []);
+ }
+
+ // name (as written in the markup) -> minted token; lets a name repeated within one
+ // command map to a single token and a single Parameter.
+ var nameToToken = new Dictionary(StringComparer.Ordinal);
+ var usedTokens = new HashSet(StringComparer.Ordinal);
+ var parameters = new List();
+
+ var rewritten = TagRegex().Replace(command, match =>
+ {
+ var attributes = ParseAttributes(match.Groups["attrs"].Value);
+ if (!attributes.TryGetValue("name", out var rawName) || string.IsNullOrWhiteSpace(rawName))
+ {
+ // No usable name — leave the markup verbatim rather than guess.
+ return match.Value;
+ }
+
+ var isChoice = string.Equals(match.Groups["kind"].Value, "choice", StringComparison.Ordinal);
+ _ = attributes.TryGetValue("value", out var rawValue);
+ rawValue ??= string.Empty;
+
+ if (!nameToToken.TryGetValue(rawName, out var token))
+ {
+ token = MintToken(rawName, usedTokens);
+ nameToToken[rawName] = token;
+ _ = usedTokens.Add(token);
+ parameters.Add(BuildParameter(token, isChoice, rawValue));
+ }
+
+ return "{" + token + "}";
+ });
+
+ return new ScTranslation(rewritten, parameters);
+ }
+
+ private static Parameter BuildParameter(string token, bool isChoice, string rawValue)
+ {
+ var value = Sanitise(rawValue);
+ if (isChoice)
+ {
+ var options = value
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Take(_maxOptions)
+ .ToList();
+ return new Parameter
+ {
+ Name = token,
+ Type = ParameterType.Choice,
+ Options = options,
+ Default = options.Count > 0 ? options[0] : null,
+ };
+ }
+
+ return new Parameter
+ {
+ Name = token,
+ Type = ParameterType.Text,
+ Default = value.Length == 0 ? null : value,
+ };
+ }
+
+ private static Dictionary ParseAttributes(string attrs)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (Match attr in AttrRegex().Matches(attrs))
+ {
+ // First occurrence wins; ignore duplicate keys.
+ _ = result.TryAdd(attr.Groups["key"].Value, attr.Groups["val"].Value);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Turns a markup name into a legal Snipdeck token matching [A-Za-z_][A-Za-z0-9_]*,
+ /// disambiguating against tokens already used in the same command.
+ ///
+ private static string MintToken(string rawName, HashSet usedTokens)
+ {
+ var slug = Slugify(rawName);
+ if (!usedTokens.Contains(slug))
+ {
+ return slug;
+ }
+
+ for (var suffix = 2; ; suffix++)
+ {
+ var candidate = slug + "_" + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ if (!usedTokens.Contains(candidate))
+ {
+ return candidate;
+ }
+ }
+ }
+
+ private static string Slugify(string rawName)
+ {
+ var name = Sanitise(rawName);
+ if (name.Length > _maxNameLength)
+ {
+ name = name[.._maxNameLength];
+ }
+
+ // Already a legal identifier? Keep it as-is so "authId" / "customerId" survive verbatim.
+ if (IdentifierRegex().IsMatch(name))
+ {
+ return name;
+ }
+
+ // Otherwise build PascalCase-ish from alphanumeric runs: "Agreement ID" -> "AgreementID",
+ // "Order ID / Agreement ID" -> "OrderIDAgreementID".
+ var parts = AlphanumericRunRegex().Matches(name);
+ if (parts.Count == 0)
+ {
+ return "param";
+ }
+
+ var builder = new StringBuilder();
+ for (var i = 0; i < parts.Count; i++)
+ {
+ var part = parts[i].Value;
+ if (i == 0)
+ {
+ _ = builder.Append(part);
+ }
+ else
+ {
+ _ = builder.Append(char.ToUpperInvariant(part[0]));
+ if (part.Length > 1)
+ {
+ _ = builder.Append(part[1..]);
+ }
+ }
+ }
+
+ // parts is non-empty here and every run contributes at least one character, so the
+ // slug is never empty. A leading digit is illegal for a token; prefix an underscore.
+ var slug = builder.ToString();
+ return char.IsDigit(slug[0]) ? "_" + slug : slug;
+ }
+
+ /// Strips control and ANSI escape characters and caps length.
+ private static string Sanitise(string value)
+ {
+ var builder = new StringBuilder(value.Length);
+ foreach (var ch in value)
+ {
+ if (!char.IsControl(ch))
+ {
+ _ = builder.Append(ch);
+ }
+
+ if (builder.Length >= _maxValueLength)
+ {
+ break;
+ }
+ }
+
+ return builder.ToString().Trim();
+ }
+
+ [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant)]
+ private static partial Regex IdentifierRegex();
+
+ [GeneratedRegex("[A-Za-z0-9]+", RegexOptions.CultureInvariant)]
+ private static partial Regex AlphanumericRunRegex();
+ }
+
+ /// The rewritten command template plus the parameters extracted from its markup.
+ public sealed record ScTranslation(string CommandTemplate, IReadOnlyList Parameters);
+}