openclacky 1.3.8 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,7 @@ it's live.
11
11
  ## Before publishing
12
12
 
13
13
  - The extension must live in the **local** layer (`~/.clacky/ext/local/<id>/`). Only
14
- local containers can be packed. Marketplace-origin and encrypted (`SKILL.md.enc`)
15
- containers are rejected.
14
+ local containers can be packed. Encrypted (`SKILL.md.enc`) containers are rejected.
16
15
  - Publishing requires an **activated user license** — it proves creator identity, and
17
16
  the platform attributes the extension to that account. If activation is missing, tell
18
17
  the user to activate first; don't try to work around it.
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  agents:
11
10
  - id: general
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  panels:
11
10
  - id: git
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  api: api/handler.rb
11
10
  panels:
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  panels:
11
10
  - id: time_machine
@@ -30,13 +30,14 @@ module Clacky
30
30
  Route = Struct.new(:method, :pattern, :regex, :param_names, :block, :options, keyword_init: true)
31
31
 
32
32
  class Halt < StandardError
33
- attr_reader :status, :payload, :content_type
33
+ attr_reader :status, :payload, :content_type, :extra_headers
34
34
 
35
- def initialize(status, payload, content_type)
35
+ def initialize(status, payload, content_type, extra_headers: {})
36
36
  super("api_ext halt #{status}")
37
37
  @status = status
38
38
  @payload = payload
39
39
  @content_type = content_type
40
+ @extra_headers = extra_headers
40
41
  end
41
42
  end
42
43
 
@@ -201,6 +202,14 @@ module Clacky
201
202
  raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
202
203
  end
203
204
 
205
+ def send_data(bytes, content_type:, filename: nil, status: 200)
206
+ disposition = filename ? "attachment; filename=\"#{filename}\"" : "attachment"
207
+ raise Halt.new(status, bytes, content_type, extra_headers: {
208
+ "Content-Disposition" => disposition,
209
+ "Content-Length" => bytes.bytesize.to_s
210
+ })
211
+ end
212
+
204
213
  def error!(message, status: 400, **extra)
205
214
  payload = { error: message.to_s }
206
215
  payload.merge!(extra) unless extra.empty?
@@ -97,7 +97,7 @@ module Clacky
97
97
  # Handler exited without writing — empty 204
98
98
  empty_response(res)
99
99
  rescue Clacky::ApiExtension::Halt => halt
100
- write_response(res, halt.status, halt.payload, halt.content_type)
100
+ write_response(res, halt.status, halt.payload, halt.content_type, halt.extra_headers)
101
101
  rescue Timeout::Error
102
102
  Clacky::Logger.warn("[api_ext:#{klass.ext_id}] Timed out after #{timeout_sec}s on #{route.method.upcase} #{route.pattern}")
103
103
  write_json(res, 503, error: "request timed out")
@@ -106,10 +106,11 @@ module Clacky
106
106
  write_json(res, 500, error: e.message)
107
107
  end
108
108
 
109
- private def write_response(res, status, body, content_type)
109
+ private def write_response(res, status, body, content_type, extra_headers = {})
110
110
  res.status = status
111
111
  res.content_type = content_type
112
112
  res["Access-Control-Allow-Origin"] = "*"
113
+ extra_headers.each { |k, v| res[k] = v }
113
114
  res.body = body
114
115
  end
115
116
 
@@ -98,12 +98,6 @@ module Clacky
98
98
  # carrying encrypted skills is produced by the platform pipeline; refuse
99
99
  # to re-pack it so we never smuggle marketplace artifacts out of band.
100
100
  private def refuse_protected!(slug, container_dir)
101
- manifest = YAML.safe_load(File.read(File.join(container_dir, MANIFEST)), permitted_classes: [Symbol]) || {}
102
- origin = (manifest["origin"] || "self").to_s
103
- if origin == "marketplace"
104
- raise Error, "refusing to pack #{slug}: origin=marketplace is produced by the platform, not hand-packed"
105
- end
106
-
107
101
  enc = Dir.glob(File.join(container_dir, "**", "SKILL.md.enc"))
108
102
  return if enc.empty?
109
103
 
@@ -5,7 +5,7 @@ module Clacky
5
5
  EN = {
6
6
  "llm.error.insufficient_credit" => "Insufficient credit, please top up your account to continue",
7
7
  "llm.error.rate_limit_400" => "Rate limit or service issue, retrying...",
8
- "llm.error.invalid_api_key" => "Invalid API key, please check your configuration",
8
+ "llm.error.invalid_api_key" => "API key is invalid or expired, please update it in Settings",
9
9
  "llm.error.403.model_not_allowed" => "This model is not available on your current plan",
10
10
  "llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
11
11
  "llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
@@ -5,7 +5,7 @@ module Clacky
5
5
  ZH = {
6
6
  "llm.error.insufficient_credit" => "账户余额不足,请前往控制台充值后继续使用",
7
7
  "llm.error.rate_limit_400" => "请求频率过高或服务暂时不可用,正在重试...",
8
- "llm.error.invalid_api_key" => "API 密钥无效,请检查配置",
8
+ "llm.error.invalid_api_key" => "API Key 无效或已过期,请到设置中重新配置",
9
9
  "llm.error.403.model_not_allowed" => "当前模型不支持免费试用,请升级套餐或切换其他模型",
10
10
  "llm.error.403.api_key_revoked" => "API 密钥已被撤销,请前往控制台重新生成",
11
11
  "llm.error.403.api_key_expired" => "API 密钥已过期,请前往控制台重新生成",
@@ -3,7 +3,7 @@
3
3
  - Ask clarifying questions if requirements are unclear.
4
4
  - Break down complex tasks into manageable steps.
5
5
  - **USE TOOLS to create/modify files** — don't just return content.
6
- - When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply.
6
+ - When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply. Use the raw file path as-is (keep non-ASCII characters and spaces literal); never URL-encode it.
7
7
 
8
8
  ## Tool Usage Rules
9
9
 
@@ -117,6 +117,7 @@ module Clacky
117
117
  WEB_ROOT = File.expand_path("../web", __dir__)
118
118
  EXCHANGE_RATE_PRIMARY_BASE_URL = "https://open.er-api.com/v6/latest"
119
119
  EXCHANGE_RATE_FALLBACK_URL = "https://api.frankfurter.app/latest"
120
+ OSS_CDN_BASE = "https://oss.1024code.com/openclacky"
120
121
 
121
122
  # Default SOUL.md written when the user skips the onboard conversation.
122
123
  # A richer version is created by the Agent during the soul_setup phase.
@@ -538,8 +539,10 @@ module Clacky
538
539
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
539
540
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
540
541
  when ["GET", "/api/store/skills"] then api_store_skills(res)
541
- when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
542
+ when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
543
+ when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
542
544
  when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
545
+ when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
543
546
  when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
544
547
  when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
545
548
  when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
@@ -2346,6 +2349,66 @@ module Clacky
2346
2349
  end
2347
2350
  end
2348
2351
 
2352
+ # GET /api/store/extensions/installed
2353
+ #
2354
+ # Returns all locally installed extensions (all layers: builtin, installed,
2355
+ # local) regardless of whether they are still listed on the marketplace.
2356
+ def api_store_extensions_installed(res)
2357
+ result = Clacky::ExtensionLoader.load_all
2358
+ disabled = Clacky::ExtensionLoader.disabled_ids
2359
+
2360
+ local_entries = Array(result&.containers).filter_map do |ext_id, container|
2361
+ next unless container[:layer] == :installed
2362
+
2363
+ [ext_id, container]
2364
+ end.to_h
2365
+
2366
+ market_by_slug = fetch_batch_market_data(local_entries.keys)
2367
+
2368
+ extensions = local_entries.map do |ext_id, container|
2369
+ market = market_by_slug[ext_id]
2370
+ {
2371
+ "id" => ext_id,
2372
+ "name" => market ? (market["name"] || ext_id) : ext_id,
2373
+ "name_zh" => market&.dig("name_zh"),
2374
+ "name_en" => market&.dig("name_en"),
2375
+ "slug" => ext_id,
2376
+ "version" => market ? (market["version"] || container[:version]) : container[:version],
2377
+ "description" => market&.dig("description"),
2378
+ "author" => market&.dig("author"),
2379
+ "icon_url" => market&.dig("icon_url"),
2380
+ "units" => market&.dig("units"),
2381
+ "homepage" => market ? (market["homepage"] || "") : "",
2382
+ "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2383
+ "hub_active" => market&.dig("hub_active"),
2384
+ "unlisted" => market.nil?,
2385
+ "layer" => container[:layer].to_s,
2386
+ "installed" => true,
2387
+ "removable" => true,
2388
+ "disabled" => disabled.include?(ext_id),
2389
+ }
2390
+ end
2391
+
2392
+ json_response(res, 200, { ok: true, extensions: extensions })
2393
+ rescue StandardError => e
2394
+ json_response(res, 500, { ok: false, error: e.message })
2395
+ end
2396
+
2397
+ private def fetch_batch_market_data(slugs)
2398
+ return {} if slugs.empty?
2399
+
2400
+ client = Clacky::PlatformHttpClient.new
2401
+ slugs.each_slice(50).each_with_object({}) do |batch, result|
2402
+ ids_param = batch.join(",")
2403
+ response = client.get("/api/v1/extensions/batch?ids=#{ids_param}")
2404
+ next unless response[:success]
2405
+
2406
+ Array(response.dig(:data, "extensions")).each { |ext| result[ext["name"]] = ext }
2407
+ end
2408
+ rescue StandardError
2409
+ {}
2410
+ end
2411
+
2349
2412
  # Slugs of every extension container currently loaded (any layer), used to
2350
2413
  # flag "installed" on the public marketplace catalog.
2351
2414
  def installed_extension_slugs
@@ -2380,7 +2443,32 @@ module Clacky
2380
2443
  )
2381
2444
  json_response(res, 200, { ok: true, extension: ext })
2382
2445
  else
2383
- json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
2446
+ container = extension_container(id)
2447
+ if container && container[:layer] == :installed
2448
+ market = fetch_batch_market_data([id])[id]
2449
+ ext = {
2450
+ "id" => id,
2451
+ "name" => market ? (market["name"] || id) : id,
2452
+ "name_zh" => market&.dig("name_zh"),
2453
+ "name_en" => market&.dig("name_en"),
2454
+ "slug" => id,
2455
+ "version" => market ? (market["version"] || container[:version]) : container[:version],
2456
+ "description" => market&.dig("description"),
2457
+ "author" => market&.dig("author"),
2458
+ "icon_url" => market&.dig("icon_url"),
2459
+ "units" => market&.dig("units"),
2460
+ "homepage" => market ? (market["homepage"] || "") : "",
2461
+ "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2462
+ "hub_active" => market&.dig("hub_active"),
2463
+ "unlisted" => market.nil?,
2464
+ "installed" => true,
2465
+ "removable" => true,
2466
+ "disabled" => container[:disabled] == true,
2467
+ }
2468
+ json_response(res, 200, { ok: true, extension: ext })
2469
+ else
2470
+ json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
2471
+ end
2384
2472
  end
2385
2473
  end
2386
2474
 
@@ -2419,6 +2507,25 @@ module Clacky
2419
2507
  end
2420
2508
 
2421
2509
  # DELETE /api/store/extension body: { id: <slug> }
2510
+ def api_store_extension_install(req, res)
2511
+ body = parse_json_body(req)
2512
+ download_url = body["download_url"].to_s.strip
2513
+ name = body["name"].to_s.strip
2514
+
2515
+ if download_url.empty?
2516
+ json_response(res, 400, { ok: false, error: "Missing download_url." })
2517
+ return
2518
+ end
2519
+
2520
+ Clacky::ExtensionPackager.install(download_url, force: true)
2521
+ Clacky::ExtensionLoader.invalidate_cache!
2522
+ json_response(res, 200, { ok: true, name: name })
2523
+ rescue Clacky::ExtensionPackager::Error => e
2524
+ json_response(res, 422, { ok: false, error: e.message })
2525
+ rescue StandardError => e
2526
+ json_response(res, 500, { ok: false, error: e.message })
2527
+ end
2528
+
2422
2529
  def api_store_extension_uninstall(req, res)
2423
2530
  id = parse_json_body(req)["id"].to_s
2424
2531
  container = extension_container(id)
@@ -2879,7 +2986,7 @@ module Clacky
2879
2986
  require "net/http"
2880
2987
  require "uri"
2881
2988
 
2882
- oss_base = "https://oss.1024code.com/openclacky"
2989
+ oss_base = OSS_CDN_BASE
2883
2990
  latest_url = "#{oss_base}/latest.txt"
2884
2991
 
2885
2992
  Clacky::Logger.info("[Upgrade] Non-official source — fetching latest version from OSS CDN")
@@ -3051,11 +3158,12 @@ module Clacky
3051
3158
  end
3052
3159
 
3053
3160
  # Query the latest openclacky version.
3054
- # Strategy: try RubyGems official REST API first (most accurate, not affected by mirror lag),
3055
- # then fall back to `gem list -r` (respects user's configured gem source).
3056
- # Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
3161
+ # Strategy: OSS CDN latest.txt first (fast, CDN-accelerated), then RubyGems API,
3162
+ # then fall back to `gem list -r` as a last resort.
3057
3163
  private def fetch_latest_version_from_gem
3058
- fetch_latest_version_from_rubygems_api || fetch_latest_version_from_gem_command
3164
+ fetch_oss_latest_version("#{OSS_CDN_BASE}/latest.txt") ||
3165
+ fetch_latest_version_from_rubygems_api ||
3166
+ fetch_latest_version_from_gem_command
3059
3167
  end
3060
3168
 
3061
3169
  # Try RubyGems official REST API — fast and always up-to-date.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.8"
4
+ VERSION = "1.3.10"
5
5
  end
@@ -1874,9 +1874,7 @@ body {
1874
1874
  flex-direction: column;
1875
1875
  gap: 1.25rem;
1876
1876
  width: 100%;
1877
- max-width: 60rem;
1878
- margin: 0 auto;
1879
- padding: 5.5rem 1.5rem 1rem;
1877
+ padding: 6rem max(1.5rem, calc(50% - 30rem)) 1rem;
1880
1878
  }
1881
1879
  .new-session-composer {
1882
1880
  position: relative;
@@ -2007,6 +2005,11 @@ body {
2007
2005
  font-size: 0.78125rem;
2008
2006
  color: var(--color-text-tertiary);
2009
2007
  line-height: 1.4;
2008
+ flex: 1;
2009
+ overflow: hidden;
2010
+ display: -webkit-box;
2011
+ -webkit-line-clamp: 2;
2012
+ -webkit-box-orient: vertical;
2010
2013
  }
2011
2014
  .agent-card-badge {
2012
2015
  position: absolute;
@@ -2026,7 +2029,6 @@ body {
2026
2029
  color: var(--color-accent-primary);
2027
2030
  }
2028
2031
  .agent-card-author {
2029
- margin-top: 0.25rem;
2030
2032
  font-size: 0.72rem;
2031
2033
  color: var(--color-text-tertiary);
2032
2034
  opacity: 0.8;
@@ -2096,7 +2098,7 @@ body {
2096
2098
  flex-direction: column;
2097
2099
  gap: 0.5rem;
2098
2100
  align-items: flex-start;
2099
- margin-top: -0.75rem;
2101
+ margin-top: -0.5rem;
2100
2102
  }
2101
2103
  .new-session-advanced-toggle {
2102
2104
  display: inline-flex;
@@ -7079,13 +7081,45 @@ body {
7079
7081
  gap: 1.25rem;
7080
7082
  }
7081
7083
  .extensions-toolbar {
7084
+ display: flex;
7085
+ align-items: center;
7086
+ justify-content: space-between;
7087
+ gap: 0.75rem;
7088
+ margin-bottom: 0.25rem;
7089
+ }
7090
+ .extensions-toolbar-right {
7082
7091
  display: flex;
7083
7092
  align-items: center;
7084
7093
  gap: 0.5rem;
7085
7094
  }
7095
+ .extensions-filter-tabs {
7096
+ display: flex;
7097
+ align-items: center;
7098
+ gap: 0;
7099
+ flex-shrink: 0;
7100
+ }
7101
+ .extensions-filter-tab {
7102
+ font-size: 0.875rem;
7103
+ font-weight: 500;
7104
+ padding: 0.25rem 0.875rem 0.5rem;
7105
+ border: none;
7106
+ background: transparent;
7107
+ color: var(--color-text-tertiary);
7108
+ cursor: pointer;
7109
+ white-space: nowrap;
7110
+ border-bottom: 2px solid transparent;
7111
+ transition: color .15s, border-color .15s;
7112
+ }
7113
+ .extensions-filter-tab:hover {
7114
+ color: var(--color-text-primary);
7115
+ }
7116
+ .extensions-filter-tab-active {
7117
+ color: var(--color-text-primary);
7118
+ border-bottom-color: var(--color-accent, #6366f1);
7119
+ }
7086
7120
  .extensions-search {
7087
7121
  flex: 1;
7088
- min-width: 0;
7122
+ min-width: 15rem;
7089
7123
  font-size: 0.8125rem;
7090
7124
  padding: 0.4375rem 0.75rem;
7091
7125
  border-radius: 6px;
@@ -7137,7 +7171,7 @@ body {
7137
7171
  .extension-card:hover { border-color: var(--color-text-muted); }
7138
7172
  .extension-card-main {
7139
7173
  display: flex;
7140
- align-items: flex-start;
7174
+ align-items: center;
7141
7175
  gap: 0.75rem;
7142
7176
  }
7143
7177
  .extension-emoji {
@@ -7176,12 +7210,32 @@ body {
7176
7210
  border-radius: 4px;
7177
7211
  padding: 0.0625rem 0.375rem;
7178
7212
  }
7213
+ .extension-unlisted {
7214
+ font-size: 0.6875rem;
7215
+ color: var(--color-text-secondary);
7216
+ background: var(--color-surface-2, rgba(0,0,0,0.06));
7217
+ border-radius: 4px;
7218
+ padding: 0.0625rem 0.375rem;
7219
+ }
7179
7220
  .extension-desc {
7180
7221
  font-size: 0.75rem;
7181
7222
  color: var(--color-text-secondary);
7182
7223
  line-height: 1.5;
7183
7224
  margin-bottom: 0.375rem;
7184
7225
  }
7226
+ .extension-desc-detail {
7227
+ font-size: 0.8125rem;
7228
+ margin-bottom: 0.5rem;
7229
+ }
7230
+ .extension-detail-author {
7231
+ font-size: 0.6875rem;
7232
+ color: var(--color-text-muted);
7233
+ }
7234
+ .extension-author {
7235
+ font-size: 0.75rem;
7236
+ color: var(--color-text-muted);
7237
+ font-weight: 400;
7238
+ }
7185
7239
  .extension-meta {
7186
7240
  display: flex;
7187
7241
  align-items: center;
@@ -7224,19 +7278,21 @@ body {
7224
7278
  .extension-detail-hero {
7225
7279
  display: flex;
7226
7280
  align-items: flex-start;
7227
- gap: 0.875rem;
7228
- padding-bottom: 1rem;
7281
+ gap: 1rem;
7282
+ padding-bottom: 1.25rem;
7229
7283
  border-bottom: 1px solid var(--color-border-primary);
7230
7284
  }
7231
7285
  .extension-detail-heading { flex: 1; min-width: 0; }
7232
- .extension-emoji-lg { font-size: 2.25rem; }
7233
- .extension-name-lg { font-size: 1.0625rem; }
7234
- .extension-detail-block { margin-top: 1.25rem; }
7286
+ .extension-emoji-lg { font-size: 2.5rem; line-height: 1; margin-top: 0.125rem; }
7287
+ .extension-name-lg { font-size: 1.125rem; font-weight: 700; }
7288
+ .extension-detail-block { margin-top: 1.5rem; }
7235
7289
  .extension-detail-block-title {
7236
- font-size: 0.8125rem;
7290
+ font-size: 0.75rem;
7237
7291
  font-weight: 600;
7238
- color: var(--color-text-primary);
7239
- margin: 0 0 0.625rem;
7292
+ text-transform: uppercase;
7293
+ letter-spacing: 0.04em;
7294
+ color: var(--color-text-muted);
7295
+ margin: 0 0 0.75rem;
7240
7296
  }
7241
7297
  .extension-detail-section { margin-bottom: 0.875rem; }
7242
7298
  .extension-detail-section-title {
@@ -7248,18 +7304,30 @@ body {
7248
7304
  margin: 0 0 0.375rem;
7249
7305
  }
7250
7306
  .extension-contrib-list,
7251
- .extension-version-list { list-style: none; margin: 0; padding: 0; }
7252
- .extension-contrib-item {
7253
- padding: 0.5rem 0.75rem;
7254
- background: var(--color-bg-secondary);
7307
+ .extension-version-list {
7308
+ list-style: none;
7309
+ margin: 0;
7310
+ padding: 0;
7255
7311
  border: 1px solid var(--color-border-primary);
7256
- border-radius: 6px;
7257
- margin-bottom: 0.375rem;
7312
+ border-radius: 8px;
7313
+ overflow: hidden;
7314
+ background: var(--color-bg-secondary);
7315
+ }
7316
+ .extension-contrib-item {
7317
+ padding: 0.625rem 0.875rem;
7318
+ border-bottom: 1px solid var(--color-border-primary);
7319
+ background: transparent;
7320
+ border-radius: 0;
7321
+ border-left: none;
7322
+ border-right: none;
7323
+ border-top: none;
7324
+ margin-bottom: 0;
7258
7325
  }
7326
+ .extension-contrib-item:last-child { border-bottom: none; }
7259
7327
  .extension-contrib-title {
7260
7328
  display: block;
7261
7329
  font-size: 0.8125rem;
7262
- font-weight: 600;
7330
+ font-weight: 500;
7263
7331
  color: var(--color-text-primary);
7264
7332
  }
7265
7333
  .extension-contrib-desc {
@@ -7270,7 +7338,7 @@ body {
7270
7338
  margin-top: 0.125rem;
7271
7339
  }
7272
7340
  .extension-version-item {
7273
- padding: 0.5rem 0.75rem;
7341
+ padding: 0.625rem 0.875rem;
7274
7342
  border-bottom: 1px solid var(--color-border-primary);
7275
7343
  }
7276
7344
  .extension-version-item:last-child { border-bottom: none; }
@@ -7306,20 +7374,43 @@ body {
7306
7374
  padding: 0.0625rem 0.375rem;
7307
7375
  }
7308
7376
  .extension-action {
7377
+ display: inline-flex;
7378
+ align-items: center;
7309
7379
  font-size: 0.75rem;
7310
- padding: 0.3125rem 0.75rem;
7311
- border-radius: 6px;
7312
- border: 1px solid var(--color-border);
7313
- background: var(--color-surface);
7314
- color: var(--color-text);
7380
+ padding: 0.25rem 0.625rem;
7381
+ border-radius: 5px;
7382
+ border: 1px solid var(--color-border-primary);
7383
+ background: var(--color-bg-primary);
7384
+ color: var(--color-text-secondary);
7315
7385
  cursor: pointer;
7386
+ transition: background .15s, border-color .15s, opacity .15s;
7387
+ white-space: nowrap;
7388
+ }
7389
+ .extension-action:hover { background: var(--color-bg-hover); }
7390
+ .extension-action-install {
7391
+ background: var(--color-button-primary);
7392
+ color: var(--color-button-primary-text);
7393
+ border-color: transparent;
7394
+ }
7395
+ .extension-action-install:hover {
7396
+ background: var(--color-button-primary-hover);
7397
+ }
7398
+ .extension-action-install:disabled {
7399
+ opacity: 0.5;
7400
+ cursor: not-allowed;
7401
+ }
7402
+ .extension-action-disable,
7403
+ .extension-action-enable {
7404
+ color: var(--color-text-tertiary);
7316
7405
  }
7317
- .extension-action:hover { background: var(--color-surface-hover, rgba(0,0,0,0.04)); }
7318
7406
  .extension-action-remove {
7319
- color: var(--color-danger, #dc2626);
7320
- border-color: var(--color-danger-border, rgba(220,38,38,0.4));
7407
+ color: var(--color-error);
7408
+ border-color: var(--color-error-border);
7409
+ }
7410
+ .extension-action-remove:hover {
7411
+ background: var(--color-error-bg);
7412
+ border-color: var(--color-error);
7321
7413
  }
7322
- .extension-action-remove:hover { background: var(--color-danger-bg, rgba(220,38,38,0.08)); }
7323
7414
 
7324
7415
  /* Brand Skill card */
7325
7416
  .brand-skill-card {