Marketplace: data model, settings and project fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::gateway_settings::GatewaySettings;
|
use super::gateway_settings::GatewaySettings;
|
||||||
|
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
|
||||||
use super::project::{ClaudeCodeSettings, EnvVar};
|
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -135,6 +136,16 @@ pub struct AppSettings {
|
|||||||
pub gateway: GatewaySettings,
|
pub gateway: GatewaySettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
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`.
|
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||||
///
|
///
|
||||||
/// `None` is "auto", and auto is not the same answer on every platform.
|
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||||
@@ -246,6 +257,9 @@ impl Default for AppSettings {
|
|||||||
stt: SttSettings::default(),
|
stt: SttSettings::default(),
|
||||||
gateway: GatewaySettings::default(),
|
gateway: GatewaySettings::default(),
|
||||||
global_claude_code_settings: None,
|
global_claude_code_settings: None,
|
||||||
|
marketplace_accounts: Vec::new(),
|
||||||
|
marketplaces: Vec::new(),
|
||||||
|
global_marketplace_installs: Vec::new(),
|
||||||
terminal_gpu_rendering: None,
|
terminal_gpu_rendering: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
pub mod container_config;
|
pub mod container_config;
|
||||||
pub mod gateway_settings;
|
pub mod gateway_settings;
|
||||||
|
pub mod marketplace;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
pub mod note;
|
pub mod note;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
@@ -10,6 +11,7 @@ pub mod update_info;
|
|||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
pub use container_config::*;
|
pub use container_config::*;
|
||||||
pub use gateway_settings::*;
|
pub use gateway_settings::*;
|
||||||
|
pub use marketplace::*;
|
||||||
pub use migration::*;
|
pub use migration::*;
|
||||||
pub use note::*;
|
pub use note::*;
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
|
|||||||
@@ -446,6 +446,12 @@ pub struct Project {
|
|||||||
/// User-defined display names for terminal tabs, keyed by session id.
|
/// User-defined display names for terminal tabs, keyed by session id.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub renamed_session_names: HashMap<String, String>,
|
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 created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -693,6 +699,8 @@ impl Project {
|
|||||||
claude_instructions: None,
|
claude_instructions: None,
|
||||||
claude_code_settings: None,
|
claude_code_settings: None,
|
||||||
renamed_session_names: HashMap::new(),
|
renamed_session_names: HashMap::new(),
|
||||||
|
marketplace_installs: Vec::new(),
|
||||||
|
marketplace_disabled: Vec::new(),
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
@@ -789,7 +797,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn env(key: &str, value: &str) -> EnvVar {
|
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]
|
#[test]
|
||||||
@@ -887,7 +898,10 @@ mod tests {
|
|||||||
// `merge_claude_code_settings` spells it. `main` resolved this with
|
// `merge_claude_code_settings` spells it. `main` resolved this with
|
||||||
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
|
// `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.
|
// 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!(
|
assert_eq!(
|
||||||
stored.env_scrub.or(global.env_scrub),
|
stored.env_scrub.or(global.env_scrub),
|
||||||
Some(true),
|
Some(true),
|
||||||
@@ -902,7 +916,10 @@ mod tests {
|
|||||||
let json = r#"{ "env_scrub": false }"#;
|
let json = r#"{ "env_scrub": false }"#;
|
||||||
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
|
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
|
||||||
assert_eq!(chosen.env_scrub, Some(false));
|
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));
|
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +933,10 @@ mod tests {
|
|||||||
assert_eq!(json, "{}");
|
assert_eq!(json, "{}");
|
||||||
assert!(!json.contains("null"));
|
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();
|
let json = serde_json::to_string(&partial).unwrap();
|
||||||
assert_eq!(json, r#"{"env_scrub":false}"#);
|
assert_eq!(json, r#"{"env_scrub":false}"#);
|
||||||
// And it reads back as what it is.
|
// And it reads back as what it is.
|
||||||
@@ -984,7 +1004,10 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let migrated = Project::migrate_from_value(legacy);
|
let migrated = Project::migrate_from_value(legacy);
|
||||||
let obj = migrated.as_object().unwrap();
|
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("auth_bridge_enabled"));
|
||||||
assert!(!obj.contains_key("browser_view_enabled"));
|
assert!(!obj.contains_key("browser_view_enabled"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const baseProject: Project = {
|
|||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
claude_code_settings: null,
|
claude_code_settings: null,
|
||||||
renamed_session_names: {},
|
renamed_session_names: {},
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const baseProject: Project = {
|
|||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
claude_code_settings: null,
|
claude_code_settings: null,
|
||||||
renamed_session_names: {},
|
renamed_session_names: {},
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ const project: Project = {
|
|||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
claude_code_settings: null,
|
claude_code_settings: null,
|
||||||
renamed_session_names: {},
|
renamed_session_names: {},
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
} as unknown as Project;
|
} as unknown as Project;
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const baseProject: Project = {
|
|||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
claude_code_settings: null,
|
claude_code_settings: null,
|
||||||
renamed_session_names: {},
|
renamed_session_names: {},
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const baseProject: Project = {
|
|||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
claude_code_settings: null,
|
claude_code_settings: null,
|
||||||
renamed_session_names: {},
|
renamed_session_names: {},
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const baseProject: Project = {
|
|||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
claude_code_settings: null,
|
claude_code_settings: null,
|
||||||
renamed_session_names: {},
|
renamed_session_names: {},
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const baseProject: Project = {
|
|||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
claude_code_settings: null,
|
claude_code_settings: null,
|
||||||
renamed_session_names: {},
|
renamed_session_names: {},
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ const baseProject: Project = {
|
|||||||
claude_instructions: null,
|
claude_instructions: null,
|
||||||
claude_code_settings: null,
|
claude_code_settings: null,
|
||||||
renamed_session_names: {},
|
renamed_session_names: {},
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface Project {
|
|||||||
claude_instructions: string | null;
|
claude_instructions: string | null;
|
||||||
claude_code_settings: ClaudeCodeSettings | null;
|
claude_code_settings: ClaudeCodeSettings | null;
|
||||||
renamed_session_names: Record<string, string>;
|
renamed_session_names: Record<string, string>;
|
||||||
|
marketplace_installs: MarketplaceInstall[];
|
||||||
|
marketplace_disabled: MarketplaceItemRef[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -296,6 +298,81 @@ export interface AppSettings {
|
|||||||
* canvas renderer it would otherwise fall back to. See
|
* canvas renderer it would otherwise fall back to. See
|
||||||
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
|
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
|
||||||
terminal_gpu_rendering: boolean | null;
|
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 —
|
/** What `preview_settings_import` returns before anything is applied —
|
||||||
|
|||||||
Reference in New Issue
Block a user