Marketplace: data model, settings and project fields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 08:40:58 -07:00
co-authored by Claude Opus 5.5
parent 723555bf1d
commit d419d0a6b4
13 changed files with 536 additions and 5 deletions
+14
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use super::gateway_settings::GatewaySettings;
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
use super::project::{ClaudeCodeSettings, EnvVar};
fn default_true() -> bool {
@@ -135,6 +136,16 @@ pub struct AppSettings {
pub gateway: GatewaySettings,
#[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
/// Sign-in accounts for private marketplace repos. Secrets live in the
/// OS keychain (`storage::secure::*_marketplace_token`), never here.
#[serde(default)]
pub marketplace_accounts: Vec<MarketplaceAccount>,
/// Marketplace git repos the user added.
#[serde(default)]
pub marketplaces: Vec<Marketplace>,
/// Items installed for every project (projects may opt out per item).
#[serde(default)]
pub global_marketplace_installs: Vec<MarketplaceInstall>,
/// Whether the terminal loads `@xterm/addon-webgl`.
///
/// `None` is "auto", and auto is not the same answer on every platform.
@@ -246,6 +257,9 @@ impl Default for AppSettings {
stt: SttSettings::default(),
gateway: GatewaySettings::default(),
global_claude_code_settings: None,
marketplace_accounts: Vec::new(),
marketplaces: Vec::new(),
global_marketplace_installs: Vec::new(),
terminal_gpu_rendering: None,
}
}
+399
View File
@@ -0,0 +1,399 @@
//! Marketplace data model — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
//!
//! Plain data plus the pure rules that decide what a project actually gets
//! ([`effective_installs`]) and what names are allowed to reach a container
//! path ([`is_valid_item_key`], [`marketplace_slug`]).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemKind {
Agent,
Skill,
Command,
Hook,
Plugin,
}
impl ItemKind {
/// The lowercase name used in report strings (`"agent:code-reviewer"`) and the manifest.
pub fn as_str(&self) -> &'static str {
match self {
ItemKind::Agent => "agent",
ItemKind::Skill => "skill",
ItemKind::Command => "command",
ItemKind::Hook => "hook",
ItemKind::Plugin => "plugin",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountMethod {
GhHost,
GhContainer,
Token,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MarketplaceAccount {
pub id: String,
pub label: String,
pub host: String,
pub method: AccountMethod,
#[serde(default)]
pub username: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Marketplace {
pub id: String,
pub name: String,
pub url: String,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct MarketplaceItemRef {
pub marketplace_id: String,
pub kind: ItemKind,
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MarketplaceInstall {
pub marketplace_id: String,
pub kind: ItemKind,
pub key: String,
pub commit: String,
}
impl MarketplaceInstall {
pub fn item_ref(&self) -> MarketplaceItemRef {
MarketplaceItemRef {
marketplace_id: self.marketplace_id.clone(),
kind: self.kind,
key: self.key.clone(),
}
}
}
/// What a project's container actually gets: the global installs minus the
/// ones this project opted out of, plus the project's own installs. When the
/// project installs an item that is also global, the project's entry (and so
/// its pin) wins. Sorted by item ref so the result is deterministic.
pub fn effective_installs(
global: &[MarketplaceInstall],
disabled: &[MarketplaceItemRef],
project: &[MarketplaceInstall],
) -> Vec<MarketplaceInstall> {
let mut out: BTreeMap<MarketplaceItemRef, MarketplaceInstall> = BTreeMap::new();
for install in global {
let item = install.item_ref();
if disabled.contains(&item) {
continue;
}
out.insert(item, install.clone());
}
for install in project {
out.insert(install.item_ref(), install.clone());
}
out.into_values().collect()
}
/// `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` — the only names that may become a
/// container path component. No `/`, no leading `.` or `-`, no shell
/// metacharacters.
pub fn is_valid_item_key(key: &str) -> bool {
let bytes = key.as_bytes();
if bytes.is_empty() || bytes.len() > 64 {
return false;
}
if !bytes[0].is_ascii_alphanumeric() {
return false;
}
bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
/// A container-safe, collision-free name for a marketplace: its name
/// lowercased to `[a-z0-9-]`, dashes collapsed, at most 32 characters, then
/// `-` and the first 8 characters of its id. An empty sanitised name becomes
/// `marketplace`.
pub fn marketplace_slug(name: &str, id: &str) -> String {
let mut base = String::new();
for c in name.chars() {
let c = c.to_ascii_lowercase();
if c.is_ascii_lowercase() || c.is_ascii_digit() {
base.push(c);
} else if !base.ends_with('-') && !base.is_empty() {
base.push('-');
}
}
let mut base: String = base.trim_matches('-').chars().take(32).collect();
while base.ends_with('-') {
base.pop();
}
if base.is_empty() {
base.push_str("marketplace");
}
let id_part: String = id
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.take(8)
.collect();
format!("{}-{}", base, id_part)
}
/// A full, lowercase, 40-character hex object id.
pub fn is_valid_commit(commit: &str) -> bool {
commit.len() == 40
&& commit
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogItem {
pub kind: ItemKind,
pub key: String,
pub name: String,
pub description: String,
/// Repo-relative path of the item (file or folder).
pub path: String,
/// `Some(reason)` when the item cannot be installed.
pub invalid: Option<String>,
/// Hooks only: rendered commands with `${HOOK_DIR}` substituted.
#[serde(default)]
pub hook_commands: Vec<String>,
/// Agents/commands/skills: the markdown body (≤ 64 KiB, truncated);
/// plugins: a component listing.
#[serde(default)]
pub preview: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct MarketplaceSnapshot {
pub marketplace_id: String,
pub head_commit: Option<String>,
/// RFC 3339.
pub fetched_at: Option<String>,
pub fetch_error: Option<String>,
pub items: Vec<CatalogItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemUpdate {
pub item: MarketplaceItemRef,
pub pinned: String,
pub head: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileChange {
Added,
Removed,
Modified,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileDiff {
pub path: String,
pub change: FileChange,
/// Unified diff text; `None` when either side is binary.
pub unified: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SkippedItem {
pub item: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SyncReport {
#[serde(default)]
pub installed: Vec<String>,
#[serde(default)]
pub updated: Vec<String>,
#[serde(default)]
pub removed: Vec<String>,
#[serde(default)]
pub skipped: Vec<SkippedItem>,
#[serde(default)]
pub errors: Vec<String>,
/// RFC 3339, set by the host.
#[serde(default)]
pub finished_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InstallScope {
Global,
Project { project_id: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSyncResult {
pub project_id: String,
pub report: SyncReport,
}
#[cfg(test)]
mod tests {
use super::*;
fn install(market: &str, kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
MarketplaceInstall {
marketplace_id: market.to_string(),
kind,
key: key.to_string(),
commit: commit.to_string(),
}
}
#[test]
fn effective_set_is_global_minus_disabled_plus_project() {
let global = vec![
install("m1", ItemKind::Agent, "reviewer", "a"),
install("m1", ItemKind::Hook, "notify", "a"),
];
let disabled = vec![MarketplaceItemRef {
marketplace_id: "m1".into(),
kind: ItemKind::Hook,
key: "notify".into(),
}];
let project = vec![install("m2", ItemKind::Skill, "tidy", "b")];
let got = effective_installs(&global, &disabled, &project);
assert_eq!(
got,
vec![
install("m1", ItemKind::Agent, "reviewer", "a"),
install("m2", ItemKind::Skill, "tidy", "b"),
]
);
}
#[test]
fn project_pin_wins_over_global_pin() {
let global = vec![install("m1", ItemKind::Agent, "reviewer", "old")];
let project = vec![install("m1", ItemKind::Agent, "reviewer", "new")];
let got = effective_installs(&global, &[], &project);
assert_eq!(got, vec![install("m1", ItemKind::Agent, "reviewer", "new")]);
}
#[test]
fn same_key_different_kind_are_different_items() {
let global = vec![
install("m1", ItemKind::Agent, "x", "a"),
install("m1", ItemKind::Command, "x", "a"),
];
assert_eq!(effective_installs(&global, &[], &[]).len(), 2);
}
#[test]
fn item_keys_follow_the_pattern() {
for ok in ["a", "code-reviewer", "A.b_c-9", &"x".repeat(64)] {
assert!(is_valid_item_key(ok), "{ok} should be valid");
}
for bad in [
"",
".hidden",
"-flag",
"_x",
"a/b",
"a b",
"a;rm",
"$(x)",
"ä",
"..",
&"x".repeat(65),
] {
assert!(!is_valid_item_key(bad), "{bad:?} should be invalid");
}
}
#[test]
fn slug_is_sanitised_and_suffixed_with_the_id() {
assert_eq!(
marketplace_slug("Triple-C Marketplace!", "1A2B3C4D-ffff"),
"triple-c-marketplace-1a2b3c4d"
);
assert_eq!(
marketplace_slug("***", "abcdef0123"),
"marketplace-abcdef01"
);
let long = marketplace_slug(&"x".repeat(80), "12345678");
assert_eq!(long, format!("{}-12345678", "x".repeat(32)));
}
#[test]
fn commits_must_be_full_lowercase_hex() {
assert!(is_valid_commit(&"a".repeat(40)));
assert!(!is_valid_commit(&"A".repeat(40)));
assert!(!is_valid_commit(&"a".repeat(39)));
assert!(!is_valid_commit("HEAD"));
}
#[test]
fn install_scope_serialises_tagged() {
assert_eq!(
serde_json::to_value(InstallScope::Global).unwrap(),
serde_json::json!({"type": "global"})
);
assert_eq!(
serde_json::to_value(InstallScope::Project {
project_id: "p".into()
})
.unwrap(),
serde_json::json!({"type": "project", "project_id": "p"})
);
}
#[test]
fn kinds_serialise_snake_case() {
assert_eq!(serde_json::to_value(ItemKind::Plugin).unwrap(), "plugin");
assert_eq!(
serde_json::to_value(AccountMethod::GhHost).unwrap(),
"gh_host"
);
}
#[test]
fn settings_and_projects_saved_before_the_marketplace_still_load() {
let mut settings = serde_json::to_value(crate::models::AppSettings::default()).unwrap();
for key in [
"marketplace_accounts",
"marketplaces",
"global_marketplace_installs",
] {
settings.as_object_mut().unwrap().remove(key);
}
let settings: crate::models::AppSettings = serde_json::from_value(settings).unwrap();
assert!(settings.marketplace_accounts.is_empty());
assert!(settings.marketplaces.is_empty());
assert!(settings.global_marketplace_installs.is_empty());
let mut project =
serde_json::to_value(crate::models::Project::new("p".to_string(), Vec::new())).unwrap();
for key in ["marketplace_installs", "marketplace_disabled"] {
project.as_object_mut().unwrap().remove(key);
}
let project: crate::models::Project = serde_json::from_value(project).unwrap();
assert!(project.marketplace_installs.is_empty());
assert!(project.marketplace_disabled.is_empty());
}
}
+2
View File
@@ -1,6 +1,7 @@
pub mod app_settings;
pub mod container_config;
pub mod gateway_settings;
pub mod marketplace;
pub mod migration;
pub mod note;
pub mod project;
@@ -10,6 +11,7 @@ pub mod update_info;
pub use app_settings::*;
pub use container_config::*;
pub use gateway_settings::*;
pub use marketplace::*;
pub use migration::*;
pub use note::*;
pub use project::*;
+28 -5
View File
@@ -446,6 +446,12 @@ pub struct Project {
/// User-defined display names for terminal tabs, keyed by session id.
#[serde(default)]
pub renamed_session_names: HashMap<String, String>,
/// Marketplace items installed for this project only (spec §2).
#[serde(default)]
pub marketplace_installs: Vec<super::marketplace::MarketplaceInstall>,
/// Global marketplace installs this project opts out of.
#[serde(default)]
pub marketplace_disabled: Vec<super::marketplace::MarketplaceItemRef>,
pub created_at: String,
pub updated_at: String,
}
@@ -693,6 +699,8 @@ impl Project {
claude_instructions: None,
claude_code_settings: None,
renamed_session_names: HashMap::new(),
marketplace_installs: Vec::new(),
marketplace_disabled: Vec::new(),
created_at: now.clone(),
updated_at: now,
}
@@ -789,7 +797,10 @@ mod tests {
}
fn env(key: &str, value: &str) -> EnvVar {
EnvVar { key: key.to_string(), value: value.to_string() }
EnvVar {
key: key.to_string(),
value: value.to_string(),
}
}
#[test]
@@ -887,7 +898,10 @@ mod tests {
// `merge_claude_code_settings` spells it. `main` resolved this with
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
// and it has to go on winning, because the user never turned this off.
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
let global = ClaudeCodeSettings {
env_scrub: Some(true),
..Default::default()
};
assert_eq!(
stored.env_scrub.or(global.env_scrub),
Some(true),
@@ -902,7 +916,10 @@ mod tests {
let json = r#"{ "env_scrub": false }"#;
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
assert_eq!(chosen.env_scrub, Some(false));
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
let global = ClaudeCodeSettings {
env_scrub: Some(true),
..Default::default()
};
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
}
@@ -916,7 +933,10 @@ mod tests {
assert_eq!(json, "{}");
assert!(!json.contains("null"));
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
let partial = ClaudeCodeSettings {
env_scrub: Some(false),
..Default::default()
};
let json = serde_json::to_string(&partial).unwrap();
assert_eq!(json, r#"{"env_scrub":false}"#);
// And it reads back as what it is.
@@ -984,7 +1004,10 @@ mod tests {
});
let migrated = Project::migrate_from_value(legacy);
let obj = migrated.as_object().unwrap();
assert!(obj.contains_key("paths"), "the migration should still do its own job");
assert!(
obj.contains_key("paths"),
"the migration should still do its own job"
);
assert!(!obj.contains_key("auth_bridge_enabled"));
assert!(!obj.contains_key("browser_view_enabled"));
}
@@ -32,6 +32,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -60,6 +60,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -125,6 +125,8 @@ const project: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
} as unknown as Project;
@@ -44,6 +44,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -34,6 +34,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -47,6 +47,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -39,6 +39,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
@@ -72,6 +72,8 @@ const baseProject: Project = {
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
marketplace_installs: [],
marketplace_disabled: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
+77
View File
@@ -66,6 +66,8 @@ export interface Project {
claude_instructions: string | null;
claude_code_settings: ClaudeCodeSettings | null;
renamed_session_names: Record<string, string>;
marketplace_installs: MarketplaceInstall[];
marketplace_disabled: MarketplaceItemRef[];
created_at: string;
updated_at: string;
}
@@ -296,6 +298,81 @@ export interface AppSettings {
* canvas renderer it would otherwise fall back to. See
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
terminal_gpu_rendering: boolean | null;
marketplace_accounts: MarketplaceAccount[];
marketplaces: Marketplace[];
global_marketplace_installs: MarketplaceInstall[];
}
// ── Marketplace (mirrors src-tauri/src/models/marketplace.rs) ───────────────
export type ItemKind = "agent" | "skill" | "command" | "hook" | "plugin";
export type AccountMethod = "gh_host" | "gh_container" | "token";
export interface MarketplaceAccount {
id: string;
label: string;
host: string;
method: AccountMethod;
username: string | null;
}
export interface Marketplace {
id: string;
name: string;
url: string;
branch: string | null;
account_id: string | null;
}
export interface MarketplaceItemRef {
marketplace_id: string;
kind: ItemKind;
key: string;
}
export interface MarketplaceInstall extends MarketplaceItemRef {
commit: string;
}
export interface CatalogItem {
kind: ItemKind;
key: string;
name: string;
description: string;
path: string;
invalid: string | null;
hook_commands: string[];
preview: string;
}
export interface MarketplaceSnapshot {
marketplace_id: string;
head_commit: string | null;
fetched_at: string | null;
fetch_error: string | null;
items: CatalogItem[];
}
export interface ItemUpdate {
item: MarketplaceItemRef;
pinned: string;
head: string;
}
export type FileChange = "added" | "removed" | "modified";
export interface FileDiff {
path: string;
change: FileChange;
unified: string | null;
}
export interface SkippedItem {
item: string;
reason: string;
}
export interface SyncReport {
installed: string[];
updated: string[];
removed: string[];
skipped: SkippedItem[];
errors: string[];
finished_at: string;
}
export type InstallScope = { type: "global" } | { type: "project"; project_id: string };
export interface ProjectSyncResult {
project_id: string;
report: SyncReport;
}
/** What `preview_settings_import` returns before anything is applied —