openclacky 0.9.20 → 0.9.21

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 803023a686f9005d8bbed56fa2604d6cb4a426ba2ff1c24ddf6e297242c7faed
4
- data.tar.gz: ee8738600a36146e4f56b8605a917066746b31e370a41c3e245066ec4e4e961c
3
+ metadata.gz: d48d1b62badf0b608e9f6dc6cacb6b0d2c8f92263a8df77d64f6c63ee26e3580
4
+ data.tar.gz: f4abb3c3aa7dc140a91622135c4eaff7dfbad66bca6908abfb3c76ab7c183629
5
5
  SHA512:
6
- metadata.gz: b5b27887365a698d193fee92d0672315c48963a9748509cae8a6b5426d0169f16b9fad709afa2232d80542999fc931ee48e09f2cee35100b39273b33c324b558
7
- data.tar.gz: 5c5572d8a7b34af4eb03220dbf0e566ed0d2ab7b7278a89d8801f0c9ca58b100c90dd27fd17d1548016b21c20e1e458889a97a54983309c409479b863fc8b6ca
6
+ metadata.gz: acfdcca20845b40c076f1974dde40d5d62f194af03c14541a98c02c5fd431790df3e811ac17a0d35276938bb5475a53c3e22e8c8b9f56b87d0637f7a5168d7c5
7
+ data.tar.gz: a3ea45c455a7591917801963bb4a08587ea1ab24173d42cb8d1d2718dbf0a17fdf40e11296840dc107863d98e4aa1a2dad8008542b6214a10bda9d575fec7715
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.21] - 2026-03-30
11
+
12
+ ### Fixed
13
+ - **Feishu channel setup compatibility with v2.6**: fixed Ruby 3.1 syntax incompatibility in the Feishu setup script that caused failures on newer Feishu API versions
14
+
15
+ ### Improved
16
+ - **skill-creator YAML validation**: added frontmatter schema validation for skill files, catching malformed skill definitions before they cause runtime errors
17
+
18
+ ### More
19
+ - Removed `install_simple.sh` (consolidated into `install.sh`)
20
+
10
21
  ## [0.9.20] - 2026-03-30
11
22
 
12
23
  ### Added
@@ -71,9 +71,9 @@ BOT_PERMISSIONS = %w[
71
71
  # Logging helpers
72
72
  # ---------------------------------------------------------------------------
73
73
 
74
- def step(msg) = puts("[feishu-setup] #{msg}")
75
- def ok(msg) = puts("[feishu-setup] ✅ #{msg}")
76
- def warn(msg) = puts("[feishu-setup] ⚠️ #{msg}")
74
+ def step(msg); puts("[feishu-setup] #{msg}"); end
75
+ def ok(msg); puts("[feishu-setup] ✅ #{msg}"); end
76
+ def warn(msg); puts("[feishu-setup] ⚠️ #{msg}"); end
77
77
  def fail!(msg)
78
78
  puts("[feishu-setup] ❌ #{msg}")
79
79
  exit 1
@@ -501,7 +501,7 @@ def run_setup(browser, api)
501
501
  id = s["id"].to_s
502
502
  name_to_id[name] = id if name && !id.empty?
503
503
  end
504
- ids = BOT_PERMISSIONS.filter_map { |n| name_to_id[n] }
504
+ ids = BOT_PERMISSIONS.map { |n| name_to_id[n] }.compact
505
505
  missing = BOT_PERMISSIONS.reject { |n| name_to_id.key?(n) }
506
506
  warn "#{missing.size} permissions not matched: #{missing.join(", ")}" unless missing.empty?
507
507
  fail! "No permission IDs matched. API response keys: #{name_to_id.keys.first(5).inspect}" if ids.empty?
@@ -86,6 +86,12 @@ Components to fill in:
86
86
  >
87
87
  > **YAML description gotcha**: If the description contains `word: value` patterns (colons followed by space), YAML treats them as key-value pairs and the frontmatter parse fails silently. Always wrap description values in single quotes. Avoid embedded double-quotes inside single-quoted strings (use rephrasing instead).
88
88
 
89
+ > **After writing SKILL.md — always validate and auto-fix**: Run this immediately after creating or updating any skill file:
90
+ > ```bash
91
+ > ruby SKILL_DIR/scripts/validate_skill_frontmatter.rb /path/to/new-skill/SKILL.md
92
+ > ```
93
+ > The script validates the YAML frontmatter and auto-fixes common issues (unquoted descriptions, multi-line block scalars with colons). If it prints `OK:` — you're done. If it prints `Auto-fixed and saved` — it repaired the file automatically. If it prints `ERROR` — manual fix required.
94
+
89
95
  ### Skill Writing Guide
90
96
 
91
97
  #### Anatomy of a Skill
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # validate_skill_frontmatter.rb
5
+ #
6
+ # Validates and auto-fixes the YAML frontmatter of a SKILL.md file.
7
+ #
8
+ # Usage:
9
+ # ruby validate_skill_frontmatter.rb <path/to/SKILL.md>
10
+ #
11
+ # What it does:
12
+ # 1. Parses the frontmatter between --- delimiters
13
+ # 2. If YAML is invalid OR description is not a plain String:
14
+ # - Extracts name/description via regex fallback
15
+ # - Re-wraps description in single quotes (collapsed to one line)
16
+ # - Rewrites the frontmatter in the file
17
+ # 3. Exits 0 on success (with or without auto-fix), 1 on unrecoverable error
18
+
19
+ require "yaml"
20
+
21
+ path = ARGV[0]
22
+
23
+ if path.nil? || path.strip.empty?
24
+ warn "Usage: ruby validate_skill_frontmatter.rb <path/to/SKILL.md>"
25
+ exit 1
26
+ end
27
+
28
+ unless File.exist?(path)
29
+ warn "File not found: #{path}"
30
+ exit 1
31
+ end
32
+
33
+ content = File.read(path)
34
+
35
+ # Extract frontmatter block
36
+ fm_match = content.match(/\A(---\n)(.*?)(\n---[ \t]*\n?)/m)
37
+ unless fm_match
38
+ warn "ERROR: No frontmatter block found in #{path}"
39
+ exit 1
40
+ end
41
+
42
+ prefix = fm_match[1] # "---\n"
43
+ yaml_raw = fm_match[2] # raw YAML text
44
+ suffix = fm_match[3] # "\n---\n"
45
+ body = content[fm_match.end(0)..] # rest of file after frontmatter
46
+
47
+ # Attempt normal YAML parse
48
+ parse_ok = false
49
+ data = nil
50
+ begin
51
+ data = YAML.safe_load(yaml_raw) || {}
52
+ parse_ok = data["description"].is_a?(String)
53
+ rescue Psych::Exception => e
54
+ warn "YAML parse error: #{e.message}"
55
+ end
56
+
57
+ if parse_ok
58
+ puts "OK: name=#{data['name'].inspect} description_length=#{data['description'].length}"
59
+ exit 0
60
+ end
61
+
62
+ # --- Auto-fix ---
63
+ puts "Frontmatter invalid or description broken — attempting auto-fix..."
64
+
65
+ # Regex fallback: extract name and description lines
66
+ name_match = yaml_raw.match(/^name:\s*(.+)$/)
67
+ unless name_match
68
+ warn "ERROR: Cannot extract 'name' field from frontmatter. Manual fix required."
69
+ exit 1
70
+ end
71
+ name_value = name_match[1].strip.gsub(/\A['"]|['"]\z/, "")
72
+
73
+ # description may be:
74
+ # description: some text (unquoted)
75
+ # description: 'some text' (single-quoted)
76
+ # description: "some text" (double-quoted)
77
+ # description: first line\n continuation (multi-line block scalar)
78
+ desc_match = yaml_raw.match(/^description:\s*(.+?)(?=\n[a-z]|\z)/m)
79
+ unless desc_match
80
+ warn "ERROR: Cannot extract 'description' field from frontmatter. Manual fix required."
81
+ exit 1
82
+ end
83
+
84
+ raw_desc = desc_match[1].strip
85
+
86
+ # Strip existing outer quotes if present (simple single-line quoted values)
87
+ if raw_desc.start_with?("'") && raw_desc.end_with?("'")
88
+ raw_desc = raw_desc[1..-2]
89
+ elsif raw_desc.start_with?('"') && raw_desc.end_with?('"')
90
+ raw_desc = raw_desc[1..-2]
91
+ end
92
+
93
+ # Collapse multi-line: strip leading whitespace from continuation lines
94
+ description_value = raw_desc.gsub(/\n\s+/, " ").strip
95
+
96
+ # Escape any single quotes inside the description value
97
+ description_value_escaped = description_value.gsub("'", "''")
98
+
99
+ # Extract all other frontmatter lines (everything except name: and description:)
100
+ other_lines = yaml_raw.each_line.reject do |line|
101
+ line.match?(/^(name|description):/) || line.match?(/^\s+\S/) && yaml_raw.match?(/^description:.*\n(\s+.+\n)*/m)
102
+ end
103
+
104
+ # More precise: collect lines that are not part of the name/description block
105
+ remaining = []
106
+ skip_continuation = false
107
+ yaml_raw.each_line do |line|
108
+ if line.match?(/^(name|description):/)
109
+ skip_continuation = true
110
+ next
111
+ end
112
+ if skip_continuation && line.match?(/^\s+\S/)
113
+ next # continuation of a multi-line block value
114
+ end
115
+ skip_continuation = false
116
+ remaining << line unless line.strip.empty? && remaining.empty?
117
+ end
118
+
119
+ # Rebuild frontmatter
120
+ fixed_fm_lines = []
121
+ fixed_fm_lines << "name: #{name_value}"
122
+ fixed_fm_lines << "description: '#{description_value_escaped}'"
123
+ remaining.each { |l| fixed_fm_lines << l.chomp }
124
+
125
+ # Remove trailing blank lines from remaining
126
+ fixed_fm = fixed_fm_lines.join("\n").strip
127
+
128
+ new_content = "#{prefix}#{fixed_fm}#{suffix}#{body}"
129
+
130
+ File.write(path, new_content)
131
+ puts "Auto-fixed and saved: #{path}"
132
+
133
+ # Final verification
134
+ begin
135
+ verify_content = File.read(path)
136
+ verify_match = verify_content.match(/\A---\n(.*?)\n---/m)
137
+ verify_data = YAML.safe_load(verify_match[1])
138
+ raise "description not a String" unless verify_data["description"].is_a?(String)
139
+ puts "OK: name=#{verify_data['name'].inspect} description_length=#{verify_data['description'].length}"
140
+ rescue => e
141
+ warn "ERROR: Auto-fix failed, manual intervention required: #{e.message}"
142
+ exit 1
143
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.20"
4
+ VERSION = "0.9.21"
5
5
  end
data/scripts/install.ps1 CHANGED
@@ -17,7 +17,7 @@
17
17
  # After rebooting, run the same command again to complete installation.
18
18
  #
19
19
  # Development: .\install.ps1 -Local
20
- # Uses install_simple.sh from the same directory as this script instead of CDN.
20
+ # Uses install.sh from the same directory as this script instead of CDN.
21
21
 
22
22
  param(
23
23
  [switch]$Local,
@@ -33,7 +33,7 @@ $global:DisplayCmd = if ($CommandName) { $CommandName } else { "openclacky" }
33
33
 
34
34
  $CLACKY_CDN_BASE_URL = "https://oss.1024code.com"
35
35
  $INSTALL_PS1_COMMAND = "powershell -c `"irm $CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.ps1 | iex`""
36
- $INSTALL_SCRIPT_URL = "$CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install_simple.sh"
36
+ $INSTALL_SCRIPT_URL = "$CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.sh"
37
37
  $UBUNTU_WSL_AMD64_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz"
38
38
  $UBUNTU_WSL_AMD64_SHA256_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz.sha256"
39
39
  $UBUNTU_WSL_ARM64_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-arm64-ubuntu22.04lts.rootfs.tar.gz"
@@ -245,9 +245,9 @@ function Run-InstallInWsl {
245
245
  if ($Local) {
246
246
  # Convert Windows path to WSL path (e.g. C:\foo\bar -> /mnt/c/foo/bar)
247
247
  $scriptDir = Split-Path -Parent $MyInvocation.PSCommandPath
248
- $localScript = Join-Path $scriptDir "install_simple.sh"
248
+ $localScript = Join-Path $scriptDir "install.sh"
249
249
  if (-not (Test-Path $localScript)) {
250
- Write-Fail "Local mode: install_simple.sh not found at $localScript"
250
+ Write-Fail "Local mode: install.sh not found at $localScript"
251
251
  exit 1
252
252
  }
253
253
  $wslPath = ($localScript -replace '\', '/') -replace '^([A-Za-z]):', { '/mnt/' + $args[0].Groups[1].Value.ToLower() }
data/scripts/install.sh CHANGED
@@ -368,9 +368,19 @@ ensure_ruby() {
368
368
  return 0
369
369
  fi
370
370
 
371
- # No suitable Ruby install via mise
371
+ # Linux: try apt first (fast, Ubuntu 22.04 ships Ruby 3.0)
372
+ if [ "$OS" = "Linux" ] && ([ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]); then
373
+ print_info "Installing Ruby via apt..."
374
+ if sudo apt-get install -y ruby ruby-dev 2>/dev/null && check_ruby; then
375
+ return 0
376
+ fi
377
+ print_warning "apt Ruby install failed or version too old, falling back to mise..."
378
+ fi
379
+
380
+ # Fallback: install via mise (compiles from source)
372
381
  print_step "Installing Ruby via mise..."
373
382
  detect_shell
383
+ install_linux_build_deps
374
384
 
375
385
  if ! install_mise; then
376
386
  return 1
@@ -390,29 +400,37 @@ ensure_ruby() {
390
400
  }
391
401
 
392
402
  # --------------------------------------------------------------------------
393
- # Linux: install build deps before mise/Ruby (compile fallback)
403
+ # Linux: configure apt mirror + update (always runs before any apt install)
394
404
  # --------------------------------------------------------------------------
395
- install_linux_build_deps() {
396
- if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then
397
- print_step "Installing build dependencies..."
398
-
399
- if [ "$USE_CN_MIRRORS" = true ]; then
400
- print_info "Configuring apt mirror (Aliyun)..."
401
- local codename="${VERSION_CODENAME:-jammy}"
402
- local mirror_base="https://mirrors.aliyun.com/ubuntu/"
403
- local components="main restricted universe multiverse"
404
- sudo tee /etc/apt/sources.list > /dev/null <<EOF
405
+ setup_apt_mirror() {
406
+ if [ "$DISTRO" != "ubuntu" ] && [ "$DISTRO" != "debian" ]; then return 0; fi
407
+
408
+ if [ "$USE_CN_MIRRORS" = true ]; then
409
+ print_info "Configuring apt mirror (Aliyun)..."
410
+ local codename="${VERSION_CODENAME:-jammy}"
411
+ local mirror_base="https://mirrors.aliyun.com/ubuntu/"
412
+ local components="main restricted universe multiverse"
413
+ sudo tee /etc/apt/sources.list > /dev/null <<EOF
405
414
  deb ${mirror_base} ${codename} ${components}
406
415
  deb ${mirror_base} ${codename}-updates ${components}
407
416
  deb ${mirror_base} ${codename}-backports ${components}
408
417
  deb ${mirror_base} ${codename}-security ${components}
409
418
  EOF
410
- fi
411
-
412
- sudo apt update
413
- sudo apt install -y build-essential libssl-dev libyaml-dev zlib1g-dev libgmp-dev git
414
- print_success "Build dependencies installed"
415
419
  fi
420
+
421
+ sudo apt-get update -qq
422
+ print_success "apt updated"
423
+ }
424
+
425
+ # --------------------------------------------------------------------------
426
+ # Linux: install build deps (only needed when compiling Ruby via mise)
427
+ # --------------------------------------------------------------------------
428
+ install_linux_build_deps() {
429
+ if [ "$DISTRO" != "ubuntu" ] && [ "$DISTRO" != "debian" ]; then return 0; fi
430
+
431
+ print_step "Installing build dependencies..."
432
+ sudo apt-get install -y build-essential libssl-dev libyaml-dev zlib1g-dev libgmp-dev git
433
+ print_success "Build dependencies installed"
416
434
  }
417
435
 
418
436
  # --------------------------------------------------------------------------
@@ -580,10 +598,10 @@ main() {
580
598
  detect_shell
581
599
  detect_network_region
582
600
 
583
- # Linux: install build deps first (needed if mise has to compile Ruby)
601
+ # Linux: configure apt mirror + update (always runs; build deps deferred to mise fallback)
584
602
  if [ "$OS" = "Linux" ]; then
585
603
  if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then
586
- install_linux_build_deps
604
+ setup_apt_mirror
587
605
  else
588
606
  print_error "Unsupported Linux distribution: $DISTRO"
589
607
  print_info "Please install Ruby >= 2.6.0 manually and run: gem install openclacky"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.20
4
+ version: 0.9.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -368,6 +368,7 @@ files:
368
368
  - lib/clacky/default_skills/skill-creator/scripts/run_eval.py
369
369
  - lib/clacky/default_skills/skill-creator/scripts/run_loop.py
370
370
  - lib/clacky/default_skills/skill-creator/scripts/utils.py
371
+ - lib/clacky/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb
371
372
  - lib/clacky/idle_compression_timer.rb
372
373
  - lib/clacky/json_ui_controller.rb
373
374
  - lib/clacky/message_format/anthropic.rb
@@ -482,7 +483,6 @@ files:
482
483
  - scripts/install.ps1
483
484
  - scripts/install.sh
484
485
  - scripts/install_full.sh
485
- - scripts/install_simple.sh
486
486
  - scripts/uninstall.sh
487
487
  - sig/clacky.rbs
488
488
  homepage: https://github.com/yafeilee/clacky
@@ -1,630 +0,0 @@
1
- #!/bin/bash
2
- # OpenClacky Installation Script
3
- # This script automatically detects your system and installs OpenClacky
4
-
5
- set -e
6
-
7
- # Brand configuration (populated by --brand-name / --command flags)
8
- BRAND_NAME=""
9
- BRAND_COMMAND=""
10
-
11
- # Colors for output
12
- RED='\033[0;31m'
13
- GREEN='\033[0;32m'
14
- YELLOW='\033[1;33m'
15
- BLUE='\033[0;34m'
16
- NC='\033[0m' # No Color
17
-
18
- print_info() {
19
- echo -e "${BLUE}ℹ${NC} $1"
20
- }
21
-
22
- print_success() {
23
- echo -e "${GREEN}✓${NC} $1"
24
- }
25
-
26
- print_warning() {
27
- echo -e "${YELLOW}⚠${NC} $1"
28
- }
29
-
30
- print_error() {
31
- echo -e "${RED}✗${NC} $1"
32
- }
33
-
34
- print_step() {
35
- echo -e "\n${BLUE}==>${NC} $1"
36
- }
37
-
38
- # Detect OS
39
- detect_os() {
40
- case "$(uname -s)" in
41
- Linux*) OS=Linux;;
42
- Darwin*) OS=macOS;;
43
- CYGWIN*) OS=Windows;;
44
- MINGW*) OS=Windows;;
45
- *) OS=Unknown;;
46
- esac
47
- print_info "Detected OS: $OS"
48
-
49
- if [ "$OS" = "Linux" ]; then
50
- if [ -f /etc/os-release ]; then
51
- . /etc/os-release
52
- DISTRO=$ID
53
- print_info "Detected Linux distribution: $DISTRO"
54
- else
55
- DISTRO=unknown
56
- fi
57
- fi
58
- }
59
-
60
- # Check if command exists
61
- command_exists() {
62
- command -v "$1" >/dev/null 2>&1
63
- }
64
-
65
- # Detect current shell type
66
- detect_shell() {
67
- local shell_name
68
- shell_name=$(basename "$SHELL")
69
-
70
- case "$shell_name" in
71
- zsh)
72
- CURRENT_SHELL="zsh"
73
- SHELL_RC="$HOME/.zshrc"
74
- ;;
75
- bash)
76
- CURRENT_SHELL="bash"
77
- if [ "$OS" = "macOS" ]; then
78
- SHELL_RC="$HOME/.bash_profile"
79
- else
80
- SHELL_RC="$HOME/.bashrc"
81
- fi
82
- ;;
83
- fish)
84
- CURRENT_SHELL="fish"
85
- SHELL_RC="$HOME/.config/fish/config.fish"
86
- ;;
87
- *)
88
- CURRENT_SHELL="bash"
89
- SHELL_RC="$HOME/.bashrc"
90
- ;;
91
- esac
92
-
93
- print_info "Detected shell: $CURRENT_SHELL (rc file: $SHELL_RC)"
94
- }
95
-
96
- # --------------------------------------------------------------------------
97
- # Network region detection & mirror selection
98
- # --------------------------------------------------------------------------
99
- SLOW_THRESHOLD_MS=5000
100
- NETWORK_REGION="global"
101
- USE_CN_MIRRORS=false
102
-
103
- GITHUB_RAW_BASE_URL="https://raw.githubusercontent.com"
104
- DEFAULT_RUBYGEMS_URL="https://rubygems.org"
105
- DEFAULT_MISE_INSTALL_URL="https://mise.run"
106
-
107
- CN_CDN_BASE_URL="https://oss.1024code.com"
108
- CN_MISE_INSTALL_URL="${CN_CDN_BASE_URL}/mise.sh"
109
- CN_RUBY_PRECOMPILED_URL="${CN_CDN_BASE_URL}/ruby/ruby-{version}.{platform}.tar.gz"
110
- CN_RUBYGEMS_URL="https://mirrors.aliyun.com/rubygems/"
111
- CN_GEM_BASE_URL="${CN_CDN_BASE_URL}/openclacky"
112
- CN_GEM_LATEST_URL="${CN_GEM_BASE_URL}/latest.txt"
113
-
114
- # Active values (overridden by detect_network_region)
115
- MISE_INSTALL_URL="$DEFAULT_MISE_INSTALL_URL"
116
- RUBYGEMS_INSTALL_URL="$DEFAULT_RUBYGEMS_URL"
117
- RUBY_VERSION_SPEC="ruby@3" # mise will pick latest stable
118
-
119
- _probe_url() {
120
- local url="$1"
121
- local timeout_sec=5
122
- local curl_output http_code total_time elapsed_ms
123
-
124
- curl_output=$(curl -s -o /dev/null -w "%{http_code} %{time_total}" \
125
- --connect-timeout "$timeout_sec" \
126
- --max-time "$timeout_sec" \
127
- "$url" 2>/dev/null) || true
128
- http_code="${curl_output%% *}"
129
- total_time="${curl_output#* }"
130
-
131
- if [ -z "$http_code" ] || [ "$http_code" = "000" ] || [ "$http_code" = "$curl_output" ]; then
132
- echo "timeout"
133
- else
134
- elapsed_ms=$(awk -v seconds="$total_time" 'BEGIN { printf "%d", seconds * 1000 }')
135
- echo "$elapsed_ms"
136
- fi
137
- }
138
-
139
- _is_slow_or_unreachable() {
140
- local result="$1"
141
- if [ "$result" = "timeout" ]; then
142
- return 0
143
- fi
144
- [ "$result" -ge "$SLOW_THRESHOLD_MS" ] 2>/dev/null
145
- }
146
-
147
- _format_probe_time() {
148
- local result="$1"
149
- if [ "$result" = "timeout" ]; then
150
- echo "timeout"
151
- else
152
- awk -v ms="$result" 'BEGIN { printf "%.1fs", ms / 1000 }'
153
- fi
154
- }
155
-
156
- _print_probe_result() {
157
- local label="$1"
158
- local result="$2"
159
-
160
- if [ "$result" = "timeout" ]; then
161
- print_warning "UNREACHABLE ${label}"
162
- elif _is_slow_or_unreachable "$result"; then
163
- print_warning "SLOW ($(_format_probe_time "$result")) ${label}"
164
- else
165
- print_success "OK ($(_format_probe_time "$result")) ${label}"
166
- fi
167
- }
168
-
169
- _probe_url_with_retry() {
170
- local url="$1"
171
- local max_retries="${2:-2}"
172
- local result
173
-
174
- for _ in $(seq 1 "$max_retries"); do
175
- result=$(_probe_url "$url")
176
- if ! _is_slow_or_unreachable "$result"; then
177
- echo "$result"
178
- return 0
179
- fi
180
- done
181
- echo "$result"
182
- }
183
-
184
- detect_network_region() {
185
- print_step "Network pre-flight check..."
186
- echo ""
187
-
188
- print_info "Detecting network region..."
189
- local google_result baidu_result
190
- google_result=$(_probe_url "https://www.google.com")
191
- baidu_result=$(_probe_url "https://www.baidu.com")
192
-
193
- _print_probe_result "google.com" "$google_result"
194
- _print_probe_result "baidu.com" "$baidu_result"
195
-
196
- local google_ok=false baidu_ok=false
197
- ! _is_slow_or_unreachable "$google_result" && google_ok=true
198
- ! _is_slow_or_unreachable "$baidu_result" && baidu_ok=true
199
-
200
- if [ "$google_ok" = true ]; then
201
- NETWORK_REGION="global"
202
- print_success "Region: global"
203
- elif [ "$baidu_ok" = true ]; then
204
- NETWORK_REGION="china"
205
- print_success "Region: china"
206
- else
207
- NETWORK_REGION="unknown"
208
- print_warning "Region: unknown (both unreachable)"
209
- fi
210
- echo ""
211
-
212
- if [ "$NETWORK_REGION" = "china" ]; then
213
- local cdn_result mirror_result
214
- cdn_result=$(_probe_url_with_retry "$CN_MISE_INSTALL_URL")
215
- mirror_result=$(_probe_url_with_retry "$CN_RUBYGEMS_URL")
216
-
217
- _print_probe_result "CN CDN (mise/Ruby)" "$cdn_result"
218
- _print_probe_result "Aliyun (gem)" "$mirror_result"
219
-
220
- local cdn_ok=false mirror_ok=false
221
- ! _is_slow_or_unreachable "$cdn_result" && cdn_ok=true
222
- ! _is_slow_or_unreachable "$mirror_result" && mirror_ok=true
223
-
224
- if [ "$cdn_ok" = true ] || [ "$mirror_ok" = true ]; then
225
- USE_CN_MIRRORS=true
226
- MISE_INSTALL_URL="$CN_MISE_INSTALL_URL"
227
- RUBYGEMS_INSTALL_URL="$CN_RUBYGEMS_URL"
228
- RUBY_VERSION_SPEC="ruby@3.4.8"
229
- print_info "CN mirrors applied."
230
- else
231
- print_warning "CN mirrors unreachable — falling back to global sources."
232
- fi
233
- else
234
- local rubygems_result mise_result
235
- rubygems_result=$(_probe_url_with_retry "$DEFAULT_RUBYGEMS_URL")
236
- mise_result=$(_probe_url_with_retry "$DEFAULT_MISE_INSTALL_URL")
237
-
238
- _print_probe_result "RubyGems" "$rubygems_result"
239
- _print_probe_result "mise.run" "$mise_result"
240
-
241
- _is_slow_or_unreachable "$rubygems_result" && print_warning "RubyGems is slow/unreachable."
242
- _is_slow_or_unreachable "$mise_result" && print_warning "mise.run is slow/unreachable."
243
- fi
244
-
245
- echo ""
246
- }
247
-
248
- # --------------------------------------------------------------------------
249
- # Version comparison
250
- # --------------------------------------------------------------------------
251
- version_ge() {
252
- printf '%s\n%s\n' "$2" "$1" | sort -V -C
253
- }
254
-
255
- # --------------------------------------------------------------------------
256
- # Ruby check (>= 2.6.0)
257
- # --------------------------------------------------------------------------
258
- check_ruby() {
259
- if command_exists ruby; then
260
- RUBY_VERSION=$(ruby -e 'puts RUBY_VERSION' 2>/dev/null)
261
- print_info "Found Ruby $RUBY_VERSION"
262
-
263
- if version_ge "$RUBY_VERSION" "2.6.0"; then
264
- print_success "Ruby $RUBY_VERSION is compatible (>= 2.6.0)"
265
- return 0
266
- else
267
- print_warning "Ruby $RUBY_VERSION is too old (need >= 2.6.0)"
268
- return 1
269
- fi
270
- else
271
- print_warning "Ruby not found"
272
- return 1
273
- fi
274
- }
275
-
276
- # --------------------------------------------------------------------------
277
- # Configure gem source (CN mirror if needed)
278
- # --------------------------------------------------------------------------
279
- configure_gem_source() {
280
- local gemrc="$HOME/.gemrc"
281
-
282
- if [ "$USE_CN_MIRRORS" = true ]; then
283
- # CN: point gem source to Aliyun mirror
284
- if [ -f "$gemrc" ] && grep -q "${CN_RUBYGEMS_URL}" "$gemrc" 2>/dev/null; then
285
- print_success "gem source already → ${CN_RUBYGEMS_URL}"
286
- else
287
- [ -f "$gemrc" ] && mv "$gemrc" "$HOME/.gemrc_clackybak"
288
- cat > "$gemrc" <<GEMRC
289
- :sources:
290
- - ${CN_RUBYGEMS_URL}
291
- GEMRC
292
- print_success "gem source → ${CN_RUBYGEMS_URL}"
293
- fi
294
- else
295
- # Global: restore original gemrc if we were the ones who changed it
296
- if [ -f "$gemrc" ] && grep -q "${CN_RUBYGEMS_URL}" "$gemrc" 2>/dev/null; then
297
- if [ -f "$HOME/.gemrc_clackybak" ]; then
298
- mv "$HOME/.gemrc_clackybak" "$gemrc"
299
- print_info "gem source restored from backup"
300
- else
301
- rm "$gemrc"
302
- print_info "gem source restored to default"
303
- fi
304
- fi
305
- fi
306
- }
307
-
308
- # --------------------------------------------------------------------------
309
- # mise: install (if missing) and optionally activate
310
- # --------------------------------------------------------------------------
311
- install_mise() {
312
- if ! command_exists mise && [ ! -x "$HOME/.local/bin/mise" ]; then
313
- print_info "Installing mise..."
314
- if curl -fsSL "$MISE_INSTALL_URL" | sh; then
315
- # Add mise activation to shell rc
316
- local mise_init_line='eval "$(~/.local/bin/mise activate '"$CURRENT_SHELL"')"'
317
- if [ -f "$SHELL_RC" ]; then
318
- echo "$mise_init_line" >> "$SHELL_RC"
319
- else
320
- echo "$mise_init_line" > "$SHELL_RC"
321
- fi
322
- print_info "Added mise activation to $SHELL_RC"
323
- export PATH="$HOME/.local/bin:$PATH"
324
- eval "$(~/.local/bin/mise activate bash)"
325
- print_success "mise installed"
326
- else
327
- print_error "Failed to install mise"
328
- return 1
329
- fi
330
- else
331
- export PATH="$HOME/.local/bin:$PATH"
332
- eval "$(~/.local/bin/mise activate bash)" 2>/dev/null || true
333
- print_success "mise already installed"
334
- fi
335
- }
336
-
337
- # --------------------------------------------------------------------------
338
- # Install Ruby via mise (precompiled when CN mirrors active)
339
- # --------------------------------------------------------------------------
340
- install_ruby_via_mise() {
341
- print_info "Installing Ruby via mise ($RUBY_VERSION_SPEC)..."
342
-
343
- if [ "$USE_CN_MIRRORS" = true ]; then
344
- ~/.local/bin/mise settings ruby.compile=false
345
- ~/.local/bin/mise settings ruby.precompiled_url="$CN_RUBY_PRECOMPILED_URL"
346
- print_info "Using precompiled Ruby from CN CDN"
347
- else
348
- ~/.local/bin/mise settings unset ruby.compile >/dev/null 2>&1 || true
349
- ~/.local/bin/mise settings unset ruby.precompiled_url >/dev/null 2>&1 || true
350
- fi
351
-
352
- if ~/.local/bin/mise use -g "$RUBY_VERSION_SPEC"; then
353
- eval "$(~/.local/bin/mise activate bash)"
354
- print_success "Ruby installed via mise"
355
- else
356
- print_error "Failed to install Ruby via mise"
357
- return 1
358
- fi
359
- }
360
-
361
- # --------------------------------------------------------------------------
362
- # Ensure Ruby >= 2.6 is available (install if needed)
363
- # --------------------------------------------------------------------------
364
- ensure_ruby() {
365
- print_step "Checking Ruby..."
366
-
367
- if check_ruby; then
368
- return 0
369
- fi
370
-
371
- # Linux: try apt first (fast, Ubuntu 22.04 ships Ruby 3.0)
372
- if [ "$OS" = "Linux" ] && ([ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]); then
373
- print_info "Installing Ruby via apt..."
374
- if sudo apt-get install -y ruby ruby-dev 2>/dev/null && check_ruby; then
375
- return 0
376
- fi
377
- print_warning "apt Ruby install failed or version too old, falling back to mise..."
378
- fi
379
-
380
- # Fallback: install via mise (compiles from source)
381
- print_step "Installing Ruby via mise..."
382
- detect_shell
383
- install_linux_build_deps
384
-
385
- if ! install_mise; then
386
- return 1
387
- fi
388
-
389
- if ! install_ruby_via_mise; then
390
- return 1
391
- fi
392
-
393
- # Verify
394
- if check_ruby; then
395
- return 0
396
- else
397
- print_error "Ruby installation verification failed"
398
- return 1
399
- fi
400
- }
401
-
402
- # --------------------------------------------------------------------------
403
- # Linux: configure apt mirror + update (always runs before any apt install)
404
- # --------------------------------------------------------------------------
405
- setup_apt_mirror() {
406
- if [ "$DISTRO" != "ubuntu" ] && [ "$DISTRO" != "debian" ]; then return 0; fi
407
-
408
- if [ "$USE_CN_MIRRORS" = true ]; then
409
- print_info "Configuring apt mirror (Aliyun)..."
410
- local codename="${VERSION_CODENAME:-jammy}"
411
- local mirror_base="https://mirrors.aliyun.com/ubuntu/"
412
- local components="main restricted universe multiverse"
413
- sudo tee /etc/apt/sources.list > /dev/null <<EOF
414
- deb ${mirror_base} ${codename} ${components}
415
- deb ${mirror_base} ${codename}-updates ${components}
416
- deb ${mirror_base} ${codename}-backports ${components}
417
- deb ${mirror_base} ${codename}-security ${components}
418
- EOF
419
- fi
420
-
421
- sudo apt-get update -qq
422
- print_success "apt updated"
423
- }
424
-
425
- # --------------------------------------------------------------------------
426
- # Linux: install build deps (only needed when compiling Ruby via mise)
427
- # --------------------------------------------------------------------------
428
- install_linux_build_deps() {
429
- if [ "$DISTRO" != "ubuntu" ] && [ "$DISTRO" != "debian" ]; then return 0; fi
430
-
431
- print_step "Installing build dependencies..."
432
- sudo apt-get install -y build-essential libssl-dev libyaml-dev zlib1g-dev libgmp-dev git
433
- print_success "Build dependencies installed"
434
- }
435
-
436
- # --------------------------------------------------------------------------
437
- # Set GEM_HOME to user directory when system Ruby gem dir is not writable
438
- # (avoids needing sudo for gem install)
439
- # --------------------------------------------------------------------------
440
- setup_gem_home() {
441
- local gem_dir
442
- gem_dir=$(gem environment gemdir 2>/dev/null || true)
443
-
444
- # Gem dir is writable (e.g. mise-managed Ruby) — nothing to do
445
- if [ -w "$gem_dir" ]; then
446
- return 0
447
- fi
448
-
449
- # System Ruby: redirect gems to ~/.gem/ruby/<api_version>
450
- local ruby_api
451
- ruby_api=$(ruby -e 'puts RbConfig::CONFIG["ruby_version"]' 2>/dev/null)
452
-
453
- export GEM_HOME="$HOME/.gem/ruby/${ruby_api}"
454
- export GEM_PATH="$HOME/.gem/ruby/${ruby_api}"
455
- export PATH="$HOME/.local/bin:$HOME/.gem/ruby/${ruby_api}/bin:$PATH"
456
-
457
- print_info "System Ruby detected — gems will install to ~/.gem/ruby/${ruby_api}"
458
-
459
- # Persist to shell rc (use $HOME so the line is portable)
460
- # Also add ~/.local/bin so brand wrapper commands installed there are found
461
- if [ -n "$SHELL_RC" ] && ! grep -q "GEM_HOME" "$SHELL_RC" 2>/dev/null; then
462
- {
463
- echo ""
464
- echo "# Ruby user gem dir (added by openclacky installer)"
465
- echo "export GEM_HOME=\"\$HOME/.gem/ruby/${ruby_api}\""
466
- echo "export GEM_PATH=\"\$HOME/.gem/ruby/${ruby_api}\""
467
- echo "export PATH=\"\$HOME/.local/bin:\$HOME/.gem/ruby/${ruby_api}/bin:\$PATH\""
468
- } >> "$SHELL_RC"
469
- print_info "GEM_HOME written to $SHELL_RC"
470
- fi
471
- }
472
-
473
- # --------------------------------------------------------------------------
474
- # gem install openclacky
475
- # --------------------------------------------------------------------------
476
- install_via_gem() {
477
- print_step "Installing ${DISPLAY_NAME} via gem..."
478
-
479
- configure_gem_source
480
- setup_gem_home
481
-
482
- if [ "$USE_CN_MIRRORS" = true ]; then
483
- # CN: download .gem from OSS, install dependencies from Aliyun mirror
484
- print_info "Fetching latest version from OSS..."
485
- local cn_version
486
- cn_version=$(curl -fsSL "$CN_GEM_LATEST_URL" | tr -d '[:space:]')
487
- print_info "Latest version: ${cn_version}"
488
-
489
- local gem_url="${CN_GEM_BASE_URL}/openclacky-${cn_version}.gem"
490
- local gem_file="/tmp/openclacky-${cn_version}.gem"
491
- print_info "Downloading openclacky-${cn_version}.gem from OSS..."
492
- curl -fsSL "$gem_url" -o "$gem_file"
493
- print_info "Installing gem and dependencies from Aliyun mirror..."
494
- gem install "$gem_file" --no-document --source "$CN_RUBYGEMS_URL"
495
- else
496
- print_info "Installing gem and dependencies from RubyGems..."
497
- gem install openclacky --no-document
498
- fi
499
-
500
- if [ $? -eq 0 ]; then
501
- print_success "${DISPLAY_NAME} installed successfully!"
502
- return 0
503
- else
504
- print_error "gem install failed"
505
- return 1
506
- fi
507
- }
508
-
509
- # --------------------------------------------------------------------------
510
- # Parse CLI args
511
- # --------------------------------------------------------------------------
512
- parse_args() {
513
- for arg in "$0" "$@"; do
514
- case "$arg" in
515
- --brand-name=*)
516
- BRAND_NAME="${arg#--brand-name=}"
517
- ;;
518
- --command=*)
519
- BRAND_COMMAND="${arg#--command=}"
520
- ;;
521
- esac
522
- done
523
- DISPLAY_NAME="${BRAND_NAME:-OpenClacky}"
524
- }
525
-
526
- # --------------------------------------------------------------------------
527
- # Brand setup
528
- # --------------------------------------------------------------------------
529
- setup_brand() {
530
- [ -z "$BRAND_NAME" ] && return 0
531
-
532
- local clacky_dir="$HOME/.clacky"
533
- local brand_file="$clacky_dir/brand.yml"
534
- mkdir -p "$clacky_dir"
535
-
536
- print_step "Configuring brand: $BRAND_NAME"
537
-
538
- cat > "$brand_file" <<YAML
539
- product_name: "${BRAND_NAME}"
540
- package_name: "${BRAND_COMMAND}"
541
- YAML
542
- print_success "Brand config written to $brand_file"
543
-
544
- if [ -n "$BRAND_COMMAND" ]; then
545
- local bin_dir="$HOME/.local/bin"
546
- mkdir -p "$bin_dir"
547
- local wrapper="$bin_dir/$BRAND_COMMAND"
548
- cat > "$wrapper" <<WRAPPER
549
- #!/bin/sh
550
- exec openclacky "\$@"
551
- WRAPPER
552
- chmod +x "$wrapper"
553
- print_success "Wrapper installed: $wrapper"
554
-
555
- case ":$PATH:" in
556
- *":$bin_dir:"*) ;;
557
- *)
558
- print_warning "Add to your shell profile: export PATH=\"\$HOME/.local/bin:\$PATH\""
559
- ;;
560
- esac
561
- fi
562
- }
563
-
564
- # --------------------------------------------------------------------------
565
- # Post-install info
566
- # --------------------------------------------------------------------------
567
- show_post_install_info() {
568
- local cmd="${BRAND_COMMAND:-openclacky}"
569
-
570
- echo ""
571
- echo -e " ${GREEN}${DISPLAY_NAME} installed successfully!${NC}"
572
- echo ""
573
- echo " Reload your shell:"
574
- echo ""
575
- echo -e " ${YELLOW}source ${SHELL_RC}${NC}"
576
- echo ""
577
- echo -e " ${GREEN}Web UI${NC} (recommended):"
578
- echo " $cmd server"
579
- echo " Open http://localhost:7070"
580
- echo ""
581
- echo -e " ${GREEN}Terminal${NC}:"
582
- echo " $cmd"
583
- echo ""
584
- }
585
-
586
- # --------------------------------------------------------------------------
587
- # Main
588
- # --------------------------------------------------------------------------
589
- main() {
590
- parse_args "$@"
591
-
592
- echo ""
593
- echo "${DISPLAY_NAME} Installation"
594
- echo ""
595
-
596
- detect_os
597
- detect_shell
598
- detect_network_region
599
-
600
- # Linux: configure apt mirror + update (always runs; build deps deferred to mise fallback)
601
- if [ "$OS" = "Linux" ]; then
602
- if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then
603
- setup_apt_mirror
604
- else
605
- print_error "Unsupported Linux distribution: $DISTRO"
606
- print_info "Please install Ruby >= 2.6.0 manually and run: gem install openclacky"
607
- exit 1
608
- fi
609
- elif [ "$OS" != "macOS" ]; then
610
- print_error "Unsupported OS: $OS"
611
- print_info "Please install Ruby >= 2.6.0 manually and run: gem install openclacky"
612
- exit 1
613
- fi
614
-
615
- if ! ensure_ruby; then
616
- print_error "Could not install a compatible Ruby"
617
- exit 1
618
- fi
619
-
620
- if install_via_gem; then
621
- setup_brand
622
- show_post_install_info
623
- exit 0
624
- else
625
- print_error "Failed to install ${DISPLAY_NAME}"
626
- exit 1
627
- fi
628
- }
629
-
630
- main "$@"