/profile: hot-swap Claude Code accounts and check their usage without leaving the session

Tiếng Việt · English
Một Claude Code, nhiều tài khoản, không ai phải thoát session.
Vì cái cảnh "You've reached your usage limit" lúc 2 giờ sáng, khi code đang chạy dở, không nên xảy ra với ai.
Tính năng · Cài đặt · Bắt đầu nhanh · Web Dashboard · Bảng lệnh · Session song song · Có gì mới · Xử lý sự cố · Bên trong có gì · Đa ngôn ngữ · Phát triển & Test
Bạn có tài khoản công ty, tài khoản cá nhân, tài khoản "dự phòng", và một tài khoản nữa mà chính bạn cũng không nhớ lập ra để làm gì. Mỗi lần đổi là một lần /logout, /login, mở trình duyệt, chờ redirect, và quên mất mình đang nghĩ gì.
claude-swap biến mỗi tài khoản thành một profile. Đổi bằng một lệnh, ngay trong session đang chạy. Ngữ cảnh còn nguyên, cuộc hội thoại còn nguyên, chỉ có tài khoản là khác.
Plugin chạy bằng chính Node.js mà Claude Code đã mang theo: không cần Python, không npm install, đúng 0 dependency. node_modules của dự án này nặng đúng 0 byte, và chúng tôi tự hào về điều đó.
| Nhóm | Nó làm gì cho bạn | |
|---|---|---|
| 🌐 Web Dashboard | /profile web mở một trang điều khiển trên trình duyệt: bấm một cái là đổi tài khoản, kéo thanh trượt để chỉnh cấu hình, có cả trang hướng dẫn. Dành cho những ngày không muốn gõ lệnh. | |
| 💾 Quản lý Profile | Tạo, lưu, đổi, xóa: /profile new, save, delete. Bộ tứ cơ bản. | |
| ✏️ Đổi tên & hoàn tác | /profile rename cũ mới đổi tên mà alias, liên kết, lịch sử đi theo. /profile undo quay về profile trước khi lỡ tay đổi nhầm. | |
| 🔌 Giữ nguyên MCP | Đăng nhập của các MCP server thuộc về máy, không thuộc tài khoản: đổi profile xong không phải đăng nhập lại Linear, Notion, Vercel... | |
| 📟 Status line chi tiết | Trên khung nhập luôn có một dải màu với profile, thanh 5h/7d, giờ reset và cảnh báo sắp cạn (/profile statusline để tắt/bật). | |
| 🎨 Output có màu | Lỗi đỏ, thành công xanh, bảng list thẳng cột kèm giờ reset 5h và 7d. Đọc một cái là biết. | |
| 🔤 Alias | Lười gõ work-company-production-2? Đặt w rồi /profile w. | |
| 🎛️ Picker tương tác | /profile pick cho chọn bằng phím ↑ ↓, dành cho người không nhớ nổi tên profile của mình. | |
| ⚖️ Cân bằng tải | Chia đều công việc cho các tài khoản theo least-used (ai còn nhiều quota thì làm) hoặc round-robin (lần lượt từng người). Không tài khoản nào phải gánh team. | |
| 🔔 Webhook | Báo qua Telegram, Discord, Slack hoặc webhook bất kỳ khi chạm ngưỡng hay đổi tài khoản. Điện thoại rung là biết quota sắp hết. | |
| 💰 Ngân sách | Đặt trần chi tiêu hàng tháng cho từng profile (/profile budget). Ví tiền sẽ cảm ơn bạn. | |
| 🛡️ Che email & chia sẻ an toàn | Email hiện thành us***@domain.com khi bạn share màn hình, và xuất cấu hình không kèm token (/profile mask, /profile share). | |
| ⌨️ Tab completion | Sinh script gợi ý lệnh cho Bash, Zsh, Fish (/profile completion). Gõ nửa chữ rồi bấm Tab. | |
| 🚀 Session song song | /profile run <tên> chạy thêm một Claude Code tách biệt với tài khoản khác. Hai Claude, hai tài khoản, không ai giẫm chân ai. Vẫn dùng chung skill, agent, plugin, settings và memory của bạn. | |
| 🔑 Setup-token & API key | Tạo profile thẳng từ token, không cần trình duyệt (/profile add-token), đọc được cả từ stdin để token không nằm trong lịch sử shell. | |
| 🚫 Cho nghỉ phép | /profile disable <tên> tạm loại một profile khỏi auto-switch mà không cần xóa. | |
| 🌿 Theo nhánh Git | Nhánh work-* dùng tài khoản công ty, feat/* dùng tài khoản dev, tự động. Chẳng còn cảnh lỡ tay dùng tài khoản công ty cho side project. | |
| 📁 Theo thư mục | Mở dự án nào thì tự bật đúng profile của dự án đó (file .claude-profile). | |
| 📈 Dự báo | Đo tốc độ tiêu thụ %/giờ và đoán khi nào cạn (/profile forecast). Cái gì cũng tính được, trừ deadline. | |
| ⏱️ Cooldown & tự quay về | Đếm ngược tới lúc quota 5 giờ reset, rồi tự đưa bạn về profile chính khi tài khoản đó hồi sức. | |
| 🤖 Tự đổi tài khoản | Chạm ngưỡng % hoặc dính rate limit thì tự nhảy sang tài khoản còn nhiều quota nhất hoặc reset sớm nhất. Bạn chỉ việc code tiếp. | |
| 🚨 Bảo vệ hạn mức 7 ngày | Không nhảy vào tài khoản đã dùng gần hết quota tuần (mặc định 85%). | |
| 🧠 Model affinity | Opus chạy tài khoản này, Sonnet chạy tài khoản kia (/profile affinity). | |
| 🩺 Doctor & Cleanup | Khám token hết hạn, đăng nhập MCP hết hạn, profile trùng tài khoản, file hỏng (/profile doctor, /profile cleanup). Thầy thuốc cho credentials. | |
| ☁️ Sync mã hóa | Đẩy và kéo bản sao lưu mã hóa AES-256 giữa các máy (/profile sync push / pull). | |
| 💻 Shell prompt & Tmux | Hiện profile đang dùng kèm % usage trên Starship, Zsh, Bash, Tmux, chạy dưới 5ms. | |
| ⏳ Mượn tạm | Mượn một profile trong 30m hay 1h rồi tự trả. Như mượn sạc của đồng nghiệp, nhưng lần này có người nhắc trả. | |
| 📊 JSON | --json cho list, current, disabled, để script và CI đọc được. | |
| 🌐 Song ngữ | Tiếng Việt hoặc tiếng Anh, `/profile lang [vi\ | en]`. |
| ⚡ Không tốn token | Mọi lệnh /profile chạy local qua plugin hook, không gửi gì cho model và không tốn lượt nào. Chạy ngay cả khi Claude đang bận trả lời, khỏi chờ. |
Gõ ngay trong Claude Code:
/plugin install profile-swap --marketplace thangvofastboy/claude-swap
(Claude hỏi có thêm marketplace không thì bấm y, rồi chọn phạm vi cài đặt.)
Muốn thử thẳng từ source mà chưa cài:
claude --plugin-dir /path/to/claude-swap
Đã cài rồi và muốn bản mới nhất: /profile upgrade. Plugin tự nạp lại (/reload-plugins), không cần thoát session.
Bốn bước, chưa kịp nguội ly cà phê:
claude → /login), rồi lưu lại: ``text /profile new work ``/login sang tài khoản thứ hai, lưu tiếp: ``text /profile new personal ``text /profile list # danh sách kèm thanh usage % /profile web # hoặc mở dashboard ``text /profile work ``Vậy là xong. Không còn phải nhớ mật khẩu nào là của tài khoản nào.
Cho những ngày mắt cần thứ gì đó đẹp hơn terminal:
/profile web # mở dashboard tại http://127.0.0.1:3737
/profile web --port 8080 # đổi cổng
/profile web stop # tắt dashboard đang chạy ngầm
/profile đều có form riêng: alias, tag, gắn thư mục và nhánh Git, model affinity, mượn tạm, ngân sách, webhook, sao lưu / đồng bộ mã hóa, snippet cho shell… Điền rồi bấm ▶, kết quả hiện ngay bên dưới. Mật khẩu sao lưu đi qua stdin, không nằm trên dòng lệnh.Dashboard chỉ lắng nghe trên 127.0.0.1, từ chối request lạ, và mỗi lần khởi động lại tạo một token bí mật mới. Token nằm sau dấu # trong link mà /profile web in ra, nên chỉ trình duyệt của bạn biết. Hàng xóm cùng Wi-Fi hay user khác trên cùng máy đều không đổi tài khoản giùm bạn được. Lỡ đóng tab thì chạy lại /profile web để lấy link.
/profilePhần này nghiêm túc hơn một chút, vì gõ sai lệnh thì không vui.
| Lệnh | Mô tả | |
|---|---|---|
/profile | Tổng quan (profile đang dùng, thanh quota 5h/7d, mọi cài đặt bật/tắt) rồi bảng hướng dẫn đầy đủ. Lạc đường thì gõ cái này | |
/profile list [--json] | Điểm danh cả đội: bảng thẳng cột gồm 🟢/⚪, email, thanh quota 5h/7d, giờ reset và nhãn 🏷️ | |
/profile current [--json] | Câu hỏi triết học "mình là ai?", trả lời bằng tên profile đang dùng | |
| `/profile <tên\ | alias>` | Đổi sang profile hoặc alias <tên>. Claude còn không biết mình vừa đổi chủ |
/profile pick | Chọn bằng phím ↑ ↓, cho ai không nhớ nổi tên profile của chính mình | |
/profile alias <tên> <p> | Đặt biệt danh (vd: /profile alias w work). Gõ một chữ thay vì cả câu | |
/profile unalias <tên> | Xóa biệt danh. Profile gốc vẫn bình an vô sự | |
/profile aliases | Danh bạ biệt danh | |
/profile new <tên> [--force] | Chụp ảnh tài khoản đang đăng nhập rồi cất thành profile mới | |
/profile save <tên> [--force] | Ghi tài khoản đang đăng nhập vào profile, rất hợp sau khi vừa /login lại | |
/profile rename <cũ> <mới> | Đổi tên profile. Alias, liên kết dự án/nhánh, lịch sử, ngân sách đều đi theo, không bỏ rơi ai | |
/profile delete <tên> | Chia tay dứt khoát: xóa profile và dọn đồ đạc liên quan. Không có thùng rác | |
/profile folder | Mở thư mục chứa profile. Ngó thì được, đừng sửa tay | |
| `/profile lang [vi\ | en]` | Xem hoặc đổi ngôn ngữ. Song ngữ, khỏi cần phiên dịch |
/profile version | Đang chạy bản nào |
| Lệnh | Mô tả | |||
|---|---|---|---|---|
/profile web [--port <p>] | Mở Web Dashboard, cho những ngày lười gõ lệnh | |||
/profile web stop | Cho dashboard đi ngủ | |||
| `/profile balance [on\ | off]` | Bật / tắt cân bằng tải. Khi bật, auto-switch chọn profile kế tiếp theo mode (thay cho auto order) | ||
| `/profile balance mode <least-used\ | round-robin>` | Chọn kiểu chia việc: ai còn nhiều quota nhất làm trước, hay lần lượt từng người | ||
| `/profile balance pool <tag\ | all>` | Chỉ chia việc trong nhóm có tag | ||
/profile balance next | Chuyền bóng ngay cho profile kế tiếp theo thuật toán | |||
/profile webhook [status] | Kênh báo nào đang bật. Ngoài báo đổi profile, webhook còn nhận cảnh báo khi tốc độ dùng 7 ngày sẽ làm cạn trước lúc reset và khi một profile hồi quota (mỗi chu kỳ báo một lần) | |||
| `/profile webhook set <telegram\ | discord\ | slack\ | generic> <url>` | Nối dây báo tin về Telegram, Discord, Slack hoặc một URL bất kỳ |
/profile webhook unset <type> | Tắt tiếng một kênh báo | |||
/profile webhook test | Bắn một tin thử cho chắc đường dây thông suốt | |||
/profile budget [status] | Xem trần chi tiêu hàng tháng | |||
/profile budget set <tên> <số_tiền> | Đặt trần chi tiêu cho profile. Ví tiền sẽ cảm ơn bạn | |||
/profile budget unset <tên> | Gỡ trần chi tiêu. Sống thoáng nhưng tự chịu trách nhiệm | |||
| `/profile mask [on\ | off]` | Che email khi share màn hình (us***@domain.com) | ||
/profile share [file.json] | Xuất cấu hình cho đồng đội, không kèm chìa khóa nhà (token) | |||
| `/profile completion [bash\ | zsh\ | fish]` | Gõ nửa chữ, bấm Tab, phần còn lại để shell lo |
| Lệnh | Mô tả | |
|---|---|---|
/profile run <tên> [-- cmd] | Mở thêm một Claude Code chạy tài khoản khác, song song mà không giẫm chân nhau. Dùng chung skill, agent, plugin, settings, memory | |
/profile add-token <tok> [tên] | Tạo profile thẳng từ setup-token hoặc API key, không cần trình duyệt | |
| `echo $TOK \ | node swap.js add-token - [tên]` | Đưa token qua stdin để nó không nằm lại trong lịch sử shell |
/profile upgrade | Kéo bản mới nhất về và tự nạp lại, khỏi khởi động lại Claude Code | |
/profile disable <tên> | Cho profile nghỉ phép: auto-switch sẽ không gọi nó dậy | |
/profile enable <tên> | Hết phép, quay lại vòng xoay auto-switch | |
/profile disabled [--json] | Ai đang nghỉ phép |
| Lệnh | Mô tả | ||
|---|---|---|---|
/profile usage | Quota 5h, 7d và từng model (Opus, Sonnet, Haiku…), soi tới từng phần trăm | ||
/profile auto | Auto-switch đang cấu hình ra sao | ||
/profile auto on / off | Bật / tắt tự đổi tài khoản khi vượt ngưỡng. Bật lên rồi cứ code tiếp | ||
/profile auto threshold <%> | Bao nhiêu % thì đổi (mặc định 95%) | ||
/profile auto order <ds> | Xếp hàng xem ai lên thay trước (vd: work,personal,backup) | ||
| `/profile auto pool <tag\ | all>` | Chỉ đổi qua lại trong nhóm có tag | |
| `/profile auto safeguard [on\ | off\ | <%>]` | Không nhảy vào tài khoản đã gần cạn quota tuần (mặc định 85%) |
| `/profile auto return [on\ | off]` | Tự quay về profile chính khi nó hồi sức | |
/profile auto primary <tên> | Chọn "nhà" để auto-return quay về | ||
/profile auto check | Khỏi chờ prompt kế tiếp: kiểm tra ngay, chạm ngưỡng là đổi. Mỗi prompt chỉ hỏi quota của profile đang dùng, chỉ hỏi cả đội khi cần chọn người thay | ||
/profile forecast | Bói xem bao giờ cạn quota, dựa trên tốc độ tiêu thụ thật. Tính từ lúc đo gần nhất; số liệu cũ quá 20 phút sẽ được đánh dấu "chỉ tham khảo" | ||
/profile cooldown | Đếm ngược tới lúc quota 5h hồi sức | ||
/profile doctor | Khám tổng quát: token OAuth, đăng nhập MCP, file cấu hình, kết nối | ||
/profile settings | Bảng gom mọi cài đặt (auto-switch, ngưỡng, profile chính, cân bằng tải, status line, thông báo, che email, tự sửa token, ngôn ngữ). Trong Claude Code nó mở thành bảng sửa trực tiếp: bấm để bật/tắt hoặc đổi lựa chọn, gõ số vào ô, rồi 💾 Lưu (phím s); Esc để đóng. Từ terminal: /profile settings set <key> <giá trị>, --json để đọc bằng máy | ||
/profile schedule <HH:MM-HH:MM> <tên> | Đổi profile theo lịch, vd 09:00-18:00 work, cả khung qua đêm như 22:00-06:00. Chỉ đổi một lần khi tới giờ (bạn tự đổi trong khung giờ thì vẫn giữ). Chạy cả khi tắt auto-switch. /profile schedule xem lịch, `/profile unschedule <số\ | all>` xóa | |
| `/profile repair auto on\ | off` | Tự chạy repair ở nền 6 giờ một lần khi có profile hết hạn token | |
/profile help <từ khóa> | Chỉ hiện các lệnh có từ khóa đó (vẫn bấm được để đưa xuống ô nhập) | ||
/profile repair [tên] | Làm mới token hết hạn của profile khác mà không cần chuyển sang: chạy một request rất nhỏ (claude -p, model haiku) trong session cô lập rồi lưu token mới vào profile. Không kèm tên thì sửa mọi profile cần sửa; profile không còn token thì in hướng dẫn đăng nhập lại | ||
/profile cleanup [--force] | Tìm profile trùng email/UUID hoặc token hỏng. Thêm --force là dọn thật |
| Lệnh | Mô tả |
|---|---|
/profile bind [tên] | Gắn profile cho thư mục (file .claude-profile). Mở dự án là tự đúng tài khoản |
/profile unbind | Gỡ gắn: thư mục này được tự do |
/profile bind-branch <pat> [tên] | Gắn profile theo mẫu nhánh Git (vd: feat/*, hotfix-*). Checkout là đổi luôn |
/profile unbind-branch [pat] | Cởi trói cho nhánh |
/profile branch-bindings | Nhánh nào đi với tài khoản nào |
/profile tag <tên> <tag> | Dán nhãn phân nhóm (vd: /profile tag work corp) |
/profile untag <tên> <tag> | Bóc nhãn ra. Profile không giận đâu |
/profile tags | Ai thuộc nhóm nào |
/profile affinity <model> <tên> | Cho model một tài khoản riêng (vd: opus ăn quota như tằm ăn dâu) |
/profile affinity apply <model> | Nhảy sang tài khoản đã gán cho model đó |
/profile unaffinity <model> | Model này về làm việc chung với mọi người |
/profile affinities | Model nào đi với tài khoản nào |
| Lệnh | Mô tả | |||
|---|---|---|---|---|
/profile temp <tên> [tg] | Mượn tạm profile có hẹn giờ trả (vd: 30m, 1h). Hết giờ tự trả | |||
/profile untemp | Trả sớm cho giữ uy tín, về profile gốc ngay | |||
| `/profile statusline [on\ | off\ | band\ | line]` | Status line chi tiết: profile, thanh 5h/7d, giờ reset, cảnh báo sắp cạn. Không kèm tham số thì bật/tắt. band (mặc định) là dải màu trên khung nhập, line là dòng chữ thường ghim dưới. statusline ansi in dòng có màu để nhúng vào status line riêng |
/profile prompt [shell] | Snippet để starship, zsh, bash, tmux, powershell biết bạn đang là ai | |||
| `/profile notify [on\ | off]` | Bật / tắt thông báo desktop khi đổi profile (mặc định tắt cho đỡ phiền) | ||
/profile undo | Lỡ tay đổi nhầm? Quay về profile trước đó trong một nốt nhạc (gọi lần nữa thì đi lại) | |||
/profile history [n] | Nhật ký đổi tài khoản: ai, khi nào, vì sao (mặc định 10 dòng) | |||
/profile stats [--project] | Đổi tay bao nhiêu lần, tự động bao nhiêu lần, theo dự án bao nhiêu lần; kèm usage 7 ngày theo từng dự án (ước tính từ % 5h tăng thêm khi đang làm ở thư mục đó). --project chỉ in phần usage | |||
/profile sync setup <path> | Chọn chỗ đặt két đồng bộ (thư mục Dropbox, ổ mạng…) | |||
/profile sync push | Gửi két mã hóa lên chỗ đồng bộ | |||
/profile sync pull | Mang két về máy này rồi mở ra | |||
/profile export <file> | Nhét hết profile vào két AES-256-GCM. Quên mật khẩu là chịu | |||
/profile import-enc <file> | Mở két, mang profile về. Đúng mật khẩu mới mở được | |||
/profile import <thư_mục> [--force] | Dọn nhà cho profile thô từ thư mục khác về đây |
🔑 Mật khẩu sao lưu không bao giờ được ghi xuống đĩa. Đưa nó vào bằng
--password-stdin(an toàn nhất:echo "$PW" | node swap.js export f.enc --password-stdin), biến môi trườngCLAUDE_SWAP_PASSWORD, hoặc--password <pw>(cách này để lại mật khẩu trong lịch sử shell, nên chỉ dùng khi bạn tin cái lịch sử đó).
/profile list: PROFILE EMAIL 5H 7D RESET 5H RESET 7D
───────────────────────────────────────────────────────────────────────────────────────────
🟢 work work@company.com [███░░░░░] 32% [█████░░░] 64% 2h10m 3d4h
⚪ personal user@gmail.com [███████░] 85%🔥 [███░░░░░] 40% 48m 1d9h 🏷️ side
⚪ dev dev@example.com — — — — (disabled)
Cột reset cho biết còn bao lâu nữa thì cửa sổ quota đó hồi sức, để biết nên chờ hay nên đổi.
Màu thanh usage, như đèn giao thông:
< 50%: thoải mái< 80%: bắt đầu để ý< 95%: liệu mà tiết kiệm, từ 80% có thêm 🔥≥ 95%: auto-switch đang xỏ giày/profile statusline):Bật mặc định, cập nhật mỗi khi bạn gửi prompt, thanh và phần trăm đổi màu theo mức tải (cùng bảng màu với /profile list). Có hai kiểu hiển thị:
/profile statusline band (mặc định): một dải nhiều màu vẽ ngay trên khung nhập./profile statusline line: một dòng chữ thường ghim dưới khung nhập, không có màu. Claude Code bỏ ký tự điều khiển màu của dòng ghim nên chỗ đó không thể tô màu./profile statusline không kèm tham số thì bật/tắt, on/off cũng được.
● work │ 5h [███░░░░░] 32% ⏳2h10m │ 7d [█████░░░] 64% ⏳3d4h │ ⚠ 5h ~12p
Muốn nhúng vào status line riêng của bạn (cấu hình statusLine trong settings.json): node <plugin>/swap.js statusline ansi in đúng dòng có màu đó.
⏳ là thời gian còn lại tới lúc reset. ⚠ 5h ~12p chỉ hiện khi tốc độ tiêu thụ cho thấy profile này sẽ chạm ngưỡng auto-switch trong khoảng 30 phút. Gõ /profile statusline để tắt/bật.
Status line tự làm mới mỗi phút, kể cả khi bạn không gõ gì (quota được tải lại 5 phút một lần, kể cả khi đã tắt auto-switch). ⚠ lỗi mạng / ⚠ cũ 2h nghĩa là số liệu đang hiện không còn mới. Khi auto-switch tắt mà profile đang dùng vượt 80% (5h hoặc 7d), dải hiện → <profile> 7d 31% là profile nên chuyển sang, kèm một thông báo nhỏ. Mỗi lần auto-switch tự đổi profile cũng có thông báo. Bên cạnh gợi ý có nút ⇄ <profile> (bấm chuột, hoặc ctrl+x tab để vào dải rồi phím s) để chuyển ngay. Khi 7d vượt 80%, dải hiện 7d ≈0.7%/h: phần quota còn lại chia đều cho số giờ tới lúc reset, hoặc ⚠ 7d 1.2%/h > 0.7%/h khi tốc độ 3 giờ gần đây sẽ làm cạn trước lúc reset (/profile forecast có cùng thông tin cho mọi profile). Một profile khác vừa reset sau khi gần cạn sẽ có thông báo ✅ <profile> đã hồi quota. /profile usage vẽ lịch sử 24 giờ (theo giờ) và 7 ngày (theo ngày). Gõ /profile không kèm gì: mỗi lệnh trong bảng hướng dẫn bấm được, bấm vào là lệnh đó xuống ô nhập để bạn điền nốt rồi gửi. /profile stats --json xuất số liệu cho công cụ khác. Khi hạn mức 7 ngày riêng của một model (vd Fable) vượt 80%, dải hiện Fable 7d 85% → minhvong 13%. Terminal hẹp thì dải bỏ thanh và giờ reset, chỉ giữ phần trăm. Webhook còn nhận báo cáo tuần vào sáng thứ Hai.
Các lệnh dành cho người đọc (swap, undo, history, stats, forecast, tag, alias...) được tô màu theo ý nghĩa: lỗi đỏ, thành công xanh, tên profile vàng, tiêu đề cyan. Lệnh mà hook hay shell phải đọc (current, statusline, auto check, --json) luôn là chữ thường. Muốn tắt màu: --no-color hoặc biến môi trường NO_COLOR=1.
node swap.js list
node swap.js swap work
node swap.js usage
node swap.js balance on
node swap.js web
Hai tài khoản, hai cửa sổ, cùng lúc. Không giành token của nhau:
# Mở một Claude Code riêng cho profile work
node swap.js run work
# Hoặc chạy lệnh tùy ý trong môi trường của profile work
node swap.js run work -- claude --model sonnet
Mỗi session sống ở ~/.config/claude-cli-profiles/.sessions/<tên> và dùng CLAUDE_CONFIG_DIR riêng. Skill, agent, plugin, settings, CLAUDE.md và memory được nối (symlink) từ ~/.claude sang, nên session phụ vẫn "đủ đồ nghề"; chỉ có đăng nhập là riêng theo profile. Khi session kết thúc, token đã được refresh sẽ được đồng bộ ngược về profile. Nếu bên trong session có ai đó đăng nhập sang tài khoản khác, plugin sẽ không chép nhầm tài khoản đó vào profile.
Mỗi profile là một file ~/.config/claude-cli-profiles/<tên>.json (quyền 0600, thư mục 0700, người ngoài không đọc được), gồm:
~/.claude.json: oauthAccount, primaryApiKey, customApiKeyResponses.~/.claude/.credentials.json, hoặc Keychain trên macOS.Skill, agent, plugin, settings và memory nằm trong ~/.claude/ nên không thuộc về profile nào: đổi tài khoản không làm mất cái gì trong số đó.
Khi bạn đổi profile:
~/.claude.json giữ nguyên. Như thay chìa khóa xe, không ai tháo luôn cả cái xe..bak. Mất điện giữa chừng cũng không mất profile.mcpOAuth, tức đăng nhập của các MCP server. Phần này thuộc về máy chứ không thuộc tài khoản, nên khi đổi profile plugin giữ nguyên bản đang dùng thay vì trả về bản chụp cũ của profile. Nhờ vậy MCP không bị đăng nhập lại.CLAUDE_CONFIG_DIR, plugin làm việc với thư mục đó thay cho ~/.claude.stdin chứ không nằm trên dòng lệnh, nên ps không nhìn thấy.Giữ bí mật cho đồ bí mật:
hooks/register.tsx 586 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const SUBCOMMANDS = new Set([
4 'help',
5 'list',
6 'current',
7 'usage',
8 'folder',
9 'save',
10 'swap',
11 'rename',
12 'delete',
13 'new',
14 'auto',
15 'bind',
16 'unbind',
17 'notify',
18 'tag',
19 'untag',
20 'tags',
21 'export',
22 'import-enc',
23 'history',
24 'undo',
25 'stats',
26 'cooldown',
27 'doctor',
28 'statusline',
29 'prompt',
30 'temp',
31 'untemp',
32 'alias',
33 'unalias',
34 'aliases',
35 'bind-branch',
36 'unbind-branch',
37 'branch-bindings',
38 'forecast',
39 'pick',
40 'sync',
41 'affinity',
42 'unaffinity',
43 'affinities',
44 'cleanup',
45 'lang',
46 'language',
47 'disable',
48 'enable',
49 'disabled',
50 'add-token',
51 'run',
52 'version',
53 'upgrade',
54 'web',
55 'dashboard',
56 'balance',
57 'webhook',
58 'budget',
59 'cost',
60 'mask',
61 'share',
62 'completion',
63 'repair',
64 'schedule',
65 'unschedule',
66 'settings',
67 'overview',
68])
69const USAGE =
70 'Dùng: /profile | /profile list | /profile <tên|alias> | /profile pick | /profile web | /profile balance | /profile webhook | /profile lang [vi|en] | /profile run <tên> | /profile add-token <tok> | /profile disable <tên> | /profile auto | /profile sync | /profile upgrade'
71
72// "" → help, "list" → list, "work" → swap work, "save work" → save work, "import ~/a b" → import ~/a b, "auto ..." → auto ...
73function toArgv(args: string): string[] | undefined {
74 const words = args.trim().split(/\s+/).filter(Boolean)
75 if (words.length === 0 || words[0] === '--help' || words[0] === '-h') return ['help']
76 if (words.length === 1 && words[0] === 'statusline') return ['statusline', 'toggle'] // bare CLI `statusline` prints the shell-prompt string
77 if (words[0] === 'version' || words[0] === '--version' || words[0] === '-v') return ['version']
78 if (words[0] === 'web' || words[0] === 'dashboard') {
79 if (words[1] === 'stop') return words
80 if (!words.includes('--daemon')) return [words[0], '--daemon', ...words.slice(1)]
81 }
82 if (words[0] === 'import') {
83 const path = words.slice(1).filter(w => w !== '--force').join(' ')
84 if (!path) return undefined
85 return ['import', path, ...(words.includes('--force') ? ['--force'] : [])]
86 }
87 if (SUBCOMMANDS.has(words[0])) return words
88 if (words.length === 1) return ['swap', words[0]]
89 if (words.length === 2 && words[1] === '--project') return ['swap', words[0], '--project']
90 return undefined
91}
92
93// `$.env.get` wants a literal name at its call site (so a module's variables can be listed): pass the call itself
94const safely = async (read: () => Promise<string | undefined>) => {
95 try {
96 return await read()
97 } catch {
98 return undefined
99 }
100}
101
102// Where node may live when the host was started from a GUI (Desktop app from Finder/Dock) and PATH lacks the
103// shell's additions; the login shell last, since it reads the user's profile (nvm, asdf…). The hooks module has no
104// `process`: the environment comes through `$.env`.
105async function nodeCandidates($: EngineInterface, script: string, argv: string[]): Promise<string[][]> {
106 const home = (await safely(() => $.env.get('HOME'))) || (await safely(() => $.env.get('USERPROFILE'))) || ''
107 const windows = (await safely(() => $.env.get('OS'))) === 'Windows_NT'
108 const direct = [
109 'node',
110 'nodejs',
111 '/opt/homebrew/bin/node',
112 '/usr/local/bin/node',
113 '/usr/bin/node',
114 `${home}/.volta/bin/node`,
115 'C:\\Program Files\\nodejs\\node.exe',
116 ].map(node => [node, script, ...argv])
117 const shell = (await safely(() => $.env.get('SHELL'))) || '/bin/sh'
118 return windows ? direct : [...direct, [shell, '-lc', 'exec node "$@"', 'node', script, ...argv]]
119}
120
121// the first launcher that worked, tried first from then on (a reload starts over)
122let launcher = -1
123
124// the plugin is the repo itself: swap.js sits next to .claude-plugin/ and hooks/
125async function runSwap($: EngineInterface, argv: string[]) {
126 const script = `${$.plugin.root}/swap.js`
127 // the common case, `node` on PATH, costs no `$.env` lookups
128 if (launcher <= 0) {
129 try {
130 const ran = await $.process.run(['node', script, ...argv])
131 launcher = 0
132 return ran
133 } catch {}
134 }
135 const all = await nodeCandidates($, script, argv)
136 const order = launcher > 0 ? [launcher, ...all.keys()].filter((i, n, a) => a.indexOf(i) === n) : [...all.keys()].slice(1)
137 let failure: unknown
138 for (const i of order) {
139 try {
140 const ran = await $.process.run(all[i])
141 launcher = i
142 return ran
143 } catch (error) {
144 failure = error
145 }
146 }
147 throw new Error(
148 `Không tìm thấy Node.js để chạy claude-swap (${String(failure)}). Cài Node.js 18+ rồi mở lại app; ` +
149 'nếu đã cài qua nvm/Homebrew mà app Desktop vẫn không thấy, mở Claude từ terminal hoặc thêm node vào PATH hệ thống.'
150 )
151}
152
153// What `statusLineData` in swap.js returns. Drawn as a coloured band above the prompt: the host's own status line
154// takes plain text only (one colour, prefixed with the plugin name).
155type StatusData = {
156 profile: string
157 rateLimited: boolean
158 windows: { name: string; pct: number; left: string }[]
159 warn: string
160 stale?: string // '⚠ lỗi mạng', '⚠ cũ 2h': the numbers are not current
161 suggest?: string // '→ minhvong 7d 31%': auto-switch is off and this profile is running out
162 next?: string
163 pace?: string // '7d ≈0.7%/h': the 7-day quota left per hour until its reset
164 models?: string // 'Fable 7d 85% → minhvong 13%': a model's own 7-day limit running out
165 recovered?: string[] // other profiles whose full window has reset since
166 mode?: 'line' | 'band'
167 text?: string
168}
169
170// The data swap.js last produced. `auto check` reprints it on every prompt, so only a change redraws.
171// A reload loses it; the next prompt brings it back.
172let status: StatusData | null = null
173let lastRaw: string | undefined
174
175function parseStatus(raw: string): StatusData | null {
176 try {
177 const data = JSON.parse(raw)
178 return data && typeof data.profile === 'string' && Array.isArray(data.windows) ? data : null
179 } catch {
180 return null
181 }
182}
183
184// `line` mode pins plain text under the prompt (the host drops the ESC byte of a pinned line, so no colour there);
185// `band` (default) draws the coloured band above it. Whichever is not in use is cleared:
186// a plugin's pinned text survives a reload, so the very first call must clear a line an older version left behind.
187let pinned = true
188
189// the suggestion last toasted, so one profile running out toasts once, not on every refresh
190let lastSuggest = ''
191let lastRecovered: string[] = []
192
193function showStatus($: EngineInterface, raw: string) {
194 lastRaw = raw
195 status = parseStatus(raw)
196 const suggest = status?.suggest || ''
197 if (suggest && status?.next !== lastSuggest) $.ui.toast(`⚠ ${status!.profile} sắp hết quota ${suggest}: /profile ${status!.next}`)
198 lastSuggest = suggest ? status!.next || '' : ''
199 const recovered = status?.recovered || []
200 for (const name of recovered) if (!lastRecovered.includes(name)) $.ui.toast(`✅ ${name} đã hồi quota: /profile ${name}`)
201 lastRecovered = recovered
202 const text = status && status.mode === 'line' ? status.text || undefined : undefined
203 if (text !== undefined || pinned) $.ui.status(text)
204 pinned = text !== undefined
205 $.ui.invalidate('ui.render')
206}
207
208async function refreshStatus($: EngineInterface) {
209 const { stdout } = await runSwap($, ['statusline', 'json'])
210 const raw = stdout.trim()
211 if (raw !== lastRaw) showStatus($, raw)
212}
213
214// Between prompts nothing else redraws: tick once a minute so the countdown moves and the quota (refetched by
215// `statusline json` once USAGE_TTL has passed) stays current while the session sits idle. Started once per load.
216const TICK_MS = 60_000
217let ticking = false
218
219// the band's "switch" button: the same swap `/profile <name>` does, then the band redraws for the new profile
220async function swapTo($: EngineInterface, name: string) {
221 const ran = await runSwap($, ['swap', name, '--no-color']) // a toast is plain text: `swap` output is coloured
222 $.ui.toast(`${ran.stdout}${ran.stderr}`.trim().split('\n')[0] || name)
223 await refreshStatus($)
224}
225
226// `/profile settings` editor: the rows swap.js reported, the edits not saved yet, and the last save's outcome
227type Setting = { key: string; type: 'bool' | 'number' | 'choice' | 'text'; choices?: string[]; value: boolean | number | string; desc: string }
228let settings: Setting[] = []
229let draft: Record<string, Setting['value']> = {}
230let settingsNote = ''
231
232async function loadSettings($: EngineInterface) {
233 const { stdout } = await runSwap($, ['settings', '--json'])
234 settings = JSON.parse(stdout.trim())
235 draft = {}
236}
237
238function edit($: EngineInterface, key: string, value: Setting['value']) {
239 draft = { ...draft, [key]: value }
240 settingsNote = ''
241 $.ui.invalidate('ui.render')
242}
243
244// every changed row goes through `settings set`, which validates it; a refused one stays in the draft with its error
245async function saveSettings($: EngineInterface) {
246 const errors: string[] = []
247 const changed = Object.entries(draft)
248 for (const [key, value] of changed) {
249 const text = value === true ? 'on' : value === false ? 'off' : String(value)
250 const ran = await runSwap($, ['settings', 'set', key, text, '--no-color'])
251 if (ran.exitCode !== 0) errors.push(`${key}: ${`${ran.stderr}${ran.stdout}`.trim().replace(/^❌ Lỗi: /, '')}`)
252 }
253 const failed = new Set(errors.map(e => e.split(':')[0]))
254 const kept = Object.fromEntries(Object.entries(draft).filter(([k]) => failed.has(k)))
255 await loadSettings($)
256 draft = kept
257 await refreshStatus($)
258 const saved = changed.length - errors.length
259 if (!errors.length) {
260 $.ui.toast(saved ? `✅ Đã lưu ${saved} cài đặt` : 'Không có gì thay đổi')
261 await $.ui.close({ id: 'settings' })
262 return
263 }
264 settingsNote = `⚠ Đã lưu ${saved}/${changed.length}. ${errors.join(' · ')}`
265 $.ui.invalidate('ui.render')
266}
267
268// commands that only read: they cannot change what the band shows, so they skip the extra `statusline json` process
269const READ_ONLY = new Set([
270 'help', 'overview', 'list', 'current', 'usage', 'folder', 'history', 'stats', 'cooldown', 'forecast', 'doctor', 'tags',
271 'aliases', 'disabled', 'branch-bindings', 'affinities', 'prompt', 'completion', 'version', 'share', 'export', 'web', 'dashboard',
272])
273
274// Same palette as the `/profile list` table (pctCode in swap.js): green < 50, yellow < 80, orange < 95, red above.
275// Raw colours rather than theme keys, so the band and the table look alike on any theme.
276const loadColor = (pct: number) => (pct >= 95 ? 'red' : pct >= 80 ? '#ff8700' : pct >= 50 ? 'yellow' : 'green')
277const BAR_WIDTH = 8
278
279// `/profile` help, row by row: a command (yellow in swap.js's formatHelpReport) with its description, or a title
280const SGR = /\x1b\[[0-9;]*m/g
281const HELP_ARGS = new Set(['', 'help', '--help', '-h'])
282type HelpRow = { cmd?: string; desc?: string; title?: string }
283
284// what bare `/profile` draws above the help (`overview --json`, overviewData in swap.js); the latest one, so an older help
285// in the scrollback shows the current values too
286type Overview = {
287 profile: string
288 email: string
289 profiles: number
290 disabled: number
291 rateLimited: boolean
292 stale: string
293 windows: { name: string; pct: number; left: string }[]
294 settings: (Setting & { icon: string })[]
295}
296let overview: Overview | null = null
297
298function helpRows(text: string): HelpRow[] {
299 return text.split('\n').map(line => {
300 const m = line.match(/^\s*\x1b\[1;33m(\/profile[^\x1b]*)\x1b\[0m\s*(.*)$/)
301 return m ? { cmd: m[1].trim(), desc: m[2].replace(SGR, '') } : { title: line.replace(SGR, '') }
302 })
303}
304
305// what a click puts in the prompt box: the command up to its first placeholder ("/profile alias <tên> <p>" → "/profile alias ")
306export function helpDraft(cmd: string): string {
307 const words: string[] = []
308 for (const w of cmd.split(/\s+/)) {
309 if (/[<[|]/.test(w)) break
310 words.push(w)
311 }
312 return `${words.join(' ')} `
313}
314
315export const register: Register = on => {
316 on('session.start', async ($, e, next) => {
317 await $.command.register({
318 name: 'profile',
319 description: 'Đổi tài khoản Claude ngay trong session: /profile [tên | usage | auto | cooldown | doctor | temp | bind | history]',
320 immediate: true, // runs at once even while a turn is streaming, instead of queueing behind it
321 })
322
323 try {
324 const targetDir = e.cwd || '.'
325 const boundRan = await runSwap($, ['bind', 'get', targetDir])
326 const boundOut = boundRan.stdout.trim()
327 const match = boundOut.match(/(?:đang liên kết với profile|bound to profile|profile):\s*([^\s()]+)/i)
328 if (match && match[1]) {
329 const boundProfile = match[1]
330 const curRan = await runSwap($, ['current'])
331 const curProfile = curRan.stdout.trim()
332 if (boundProfile !== curProfile) {
333 await runSwap($, ['swap', boundProfile, '--project'])
334 }
335 }
336 } catch {}
337
338 void refreshStatus($).catch(() => undefined)
339 if (!ticking) {
340 ticking = true
341 $.clock.every(TICK_MS, () => void refreshStatus($).catch(() => undefined))
342 }
343
344 return next(e)
345 })
346
347 on('prompt.submit', async ($, e, next) => {
348 try {
349 const ran = await runSwap($, ['auto', 'check'])
350 const out = `${ran.stdout}${ran.stderr}`.trim()
351 // the auto-swap notice would otherwise show nowhere: the hook only reads the [status] line
352 const swapped = out.match(/^\[auto-swap\] (🔀 .*)$/m)
353 if (swapped) $.ui.toast(swapped[1])
354 const line = out.match(/^\[status\] ?(.*)$/m)
355 if (line && line[1].trim() !== lastRaw) showStatus($, line[1].trim())
356 } catch {}
357
358 return next(e)
359 })
360
361 // coloured status band: ● profile │ 5h [███░░░░░] 34% ⏳2h10m │ 7d [██████░░] 73% ⏳3d4h │ ⚠ 5h ~12p
362 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
363 if (!status || status.mode === 'line' || e.props.hasSurvey) return next(e)
364 const { Box, Text, Button } = $.ui.resolve(e)
365 const suggested = status.next
366 // a narrow terminal drops the bars and countdowns: "● work │ 5h 34% │ 7d 73%"
367 const width = status.text ? status.text.length : status.profile.length + 4 + status.windows.length * 28 // ≈ one full window
368 const compact = typeof e.props.bodyColumns === 'number' && e.props.bodyColumns < width + 8
369 const sep = <Text color="gray"> │ </Text>
370 return (
371 <Box>
372 <Text color="green" bold>● {status.profile}</Text>
373 {status.rateLimited ? <Text color="yellow" bold> ⏳ 429</Text> : null}
374 {status.windows.map(w => {
375 const filled = Math.round((w.pct / 100) * BAR_WIDTH)
376 const color = loadColor(w.pct)
377 return (
378 <Box key={w.name}>
379 {sep}
380 <Text color="cyan" bold>{w.name} </Text>
381 {compact ? null : <Text color="gray">[</Text>}
382 {compact ? null : <Text color={color} bold>{'█'.repeat(filled)}</Text>}
383 {compact ? null : <Text color="gray">{'░'.repeat(BAR_WIDTH - filled)}] </Text>}
384 <Text color={color} bold>{String(w.pct)}%</Text>
385 {w.pct >= 80 && !compact ? <Text> 🔥</Text> : null}
386 {w.left && !compact ? <Text color="cyan"> ⏳{w.left}</Text> : null}
387 </Box>
388 )
389 })}
390 {[status.warn, status.pace, status.models, status.stale, status.suggest].filter(Boolean).map(note => (
391 <Box key={note}>{sep}<Text color="yellow" bold>{note}</Text></Box>
392 ))}
393 {suggested ? (
394 <Box>
395 <Text> </Text>
396 <Button key="swap-next" hotkey="s" plain onPress={() => void swapTo($, suggested).catch(() => undefined)}>
397 {`⇄ ${suggested}`}
398 </Button>
399 </Box>
400 ) : null}
401 </Box>
402 )
403 })
404
405 // `/profile` help: every command is a button that puts it in the prompt box, ready to complete and send
406 on('ui.render', { component: 'CommandOutput' }, ($, e, next) => {
407 const { command, args, text, isErrored } = e.props
408 if (command !== 'profile' || isErrored || !HELP_ARGS.has(args.trim().split(/\s+/)[0])) return next(e)
409 let rows = helpRows(text)
410 if (!rows.some(r => r.cmd)) return next(e) // not the help after all (a format change, no match): draw it as text
411 const { Box, Text, Button } = $.ui.resolve(e)
412 // bare `/profile`: swap.js printed the overview as text above the help; draw it from the data instead
413 const ov = HELP_ARGS.has(args.trim()) ? overview : null
414 const start = rows.findIndex(r => r.title?.includes('claude-swap —'))
415 if (ov && start > 0) rows = rows.slice(start)
416 const width = ov ? Math.max(...ov.settings.map(r => r.key.length), 8) : 0
417 return (
418 <Box flexDirection="column">
419 {ov ? (
420 <Box flexDirection="column">
421 <Text color="cyan" bold>🔀 claude-swap · Tổng quan</Text>
422 {ov.profile ? (
423 <Box>
424 <Text> 👤 </Text>
425 <Text color="green" bold>{ov.profile}</Text>
426 {ov.email ? <Text color="gray">{` ${ov.email}`}</Text> : null}
427 <Text>{` 📦 ${ov.profiles} profile`}</Text>
428 {ov.disabled ? <Text color="red">{` · 🚫 ${ov.disabled} tắt`}</Text> : null}
429 </Box>
430 ) : (
431 <Text color="yellow" bold> ⚠ Chưa có profile nào active. Tạo bằng: /profile new {'<tên>'}</Text>
432 )}
433 {ov.windows.map(w => {
434 const filled = Math.round((w.pct / 100) * 16)
435 const color = loadColor(w.pct)
436 return (
437 <Box key={`ov:${w.name}`}>
438 <Text>{w.name === '5h' ? ' ⌛ ' : ' 📅 '}</Text>
439 <Text color="cyan" bold>{w.name === '5h' ? '5 giờ ' : '7 ngày '}</Text>
440 <Text color="gray">[</Text>
441 <Text color={color} bold>{'█'.repeat(filled)}</Text>
442 <Text color="gray">{'░'.repeat(16 - filled)}] </Text>
443 <Text color={color} bold>{`${String(w.pct).padStart(3)}%`}</Text>
444 {w.pct >= 80 ? <Text> 🔥</Text> : null}
445 {w.left ? <Text color="cyan">{` ⏳ ${w.left}`}</Text> : null}
446 </Box>
447 )
448 })}
449 {ov.rateLimited ? <Text color="yellow" bold> ⏳ máy chủ usage đang bận (429), quota sẽ hiện lại sau</Text> : null}
450 {ov.stale ? <Text color="yellow" bold>{` ${ov.stale}`}</Text> : null}
451 <Text> </Text>
452 <Box>
453 <Text color="cyan" bold>🧰 Cài đặt </Text>
454 <Button key="overview-settings" plain onPress={() => void $.prompt.fill({ text: '/profile settings' }).catch(() => undefined)}>
455 <Text color="gray">(sửa: /profile settings)</Text>
456 </Button>
457 </Box>
458 {ov.settings.map(r => {
459 const v = r.value
460 const off = v === false || v === '' || v === 'off'
461 return (
462 <Box key={`ov:${r.key}`}>
463 <Text>{` ${r.icon} `}</Text>
464 <Text bold>{`${r.key.padEnd(width)} `}</Text>
465 <Text color={v === true ? 'green' : off ? 'gray' : 'cyan'} bold={!off}>
466 {(v === true ? '● bật' : v === false ? '○ tắt' : v === '' ? '—' : String(v)).padEnd(14)}
467 </Text>
468 <Text dimColor>{r.desc}</Text>
469 </Box>
470 )
471 })}
472 <Text> </Text>
473 </Box>
474 ) : null}
475 {rows.map((r, i) =>
476 r.cmd ? (
477 <Box key={`row${i}`}>
478 <Text> </Text>
479 <Button key={`help:${r.cmd}`} plain onPress={() => void $.prompt.fill({ text: helpDraft(r.cmd!) }).catch(() => undefined)}>
480 <Text color="yellow" bold>{r.cmd.padEnd(28)}</Text>
481 </Button>
482 <Text> {r.desc}</Text>
483 </Box>
484 ) : (
485 <Text key={`row${i}`} color="cyan" bold>{r.title || ' '}</Text>
486 )
487 )}
488 <Text dimColor>💡 Bấm vào một lệnh để đưa nó xuống ô nhập.</Text>
489 </Box>
490 )
491 })
492
493 // the settings editor: a toggle for on/off, a button cycling through choices, a field for numbers and text
494 on('ui.render', { component: 'Pane', requestId: 'settings' }, ($, e) => {
495 const { Box, Text, Button, Input } = $.ui.resolve(e)
496 const width = Math.max(...settings.map(r => r.key.length), 8)
497 const shown = (v: Setting['value']) => (v === '' ? '—' : String(v))
498 return (
499 <Box flexDirection="column">
500 {settings.map(r => {
501 const value = r.key in draft ? draft[r.key] : r.value
502 const changed = r.key in draft && draft[r.key] !== r.value
503 const control =
504 r.type === 'bool' ? (
505 <Button key={`set:${r.key}`} plain onPress={() => edit($, r.key, !value)}>
506 <Text color={value ? 'green' : 'gray'} bold>{value ? '● on ' : '○ off'}</Text>
507 </Button>
508 ) : r.type === 'choice' ? (
509 <Button
510 key={`set:${r.key}`}
511 plain
512 onPress={() => {
513 const list = r.choices || []
514 edit($, r.key, list[(list.indexOf(String(value)) + 1) % list.length] ?? '')
515 }}
516 >
517 <Text color="cyan" bold>{`⇄ ${shown(value)}`}</Text>
518 </Button>
519 ) : (
520 <Input key={`set:${r.key}`} value={String(value)} placeholder="—" onInput={v => edit($, r.key, v)} onSubmit={v => edit($, r.key, v)} />
521 )
522 return (
523 <Box key={`row:${r.key}`}>
524 <Text color={changed ? 'yellow' : 'white'} bold>{`${changed ? '* ' : ' '}${r.key.padEnd(width)} `}</Text>
525 {control}
526 <Text dimColor>{` ${r.desc}`}</Text>
527 </Box>
528 )
529 })}
530 {settingsNote ? <Text color="yellow">{settingsNote}</Text> : null}
531 <Box>
532 <Button key="settings-save" variant="primary" hotkey="s" onPress={() =>
533 void saveSettings($).catch(err => {
534 settingsNote = `⚠ ${String(err)}`
535 $.ui.invalidate('ui.render')
536 })
537 }>
538 💾 Lưu
539 </Button>
540 <Text> </Text>
541 <Button key="settings-cancel" role="dismiss" onPress={() => void $.ui.close({ id: 'settings' })}>
542 Hủy
543 </Button>
544 <Text dimColor>{` ${Object.keys(draft).length} thay đổi chưa lưu`}</Text>
545 </Box>
546 </Box>
547 )
548 })
549
550 on('command.run', { command: 'profile' }, async ($, e) => {
551 const argv = toArgv(e.args)
552 if (!argv) return { text: USAGE }
553
554 // bare `/profile settings` opens the editor; with arguments (`settings set k v`) it runs as any command
555 if (argv[0] === 'settings' && argv.length === 1) {
556 await loadSettings($)
557 settingsNote = ''
558 const opened = await $.ui.open({ id: 'settings', title: 'claude-swap · Cài đặt', focus: true, closeOnEscape: true })
559 if (opened.isPlaced) return { text: '⚙️ Đã mở bảng cài đặt: sửa rồi bấm 💾 Lưu (phím s), Esc để đóng.' }
560 await $.ui.close({ id: 'settings' })
561 }
562
563 // bare `/profile`: the overview's data, for the render hook to draw in colour above the help
564 const [ran, ov] = await Promise.all([
565 runSwap($, argv),
566 argv.length === 1 && argv[0] === 'help' ? runSwap($, ['overview', '--json']).catch(() => null) : null,
567 ])
568 if (ov) {
569 try {
570 overview = JSON.parse(ov.stdout.trim())
571 } catch {}
572 }
573 if (!READ_ONLY.has(argv[0]) || ran.exitCode !== 0) await refreshStatus($)
574 const out = `${ran.stdout}${ran.stderr}`.trim()
575 // the new version is only on disk: have the host re-read plugins once this command has returned
576 if (argv[0] === 'upgrade' && ran.exitCode === 0) {
577 $.clock.after(500, () => void $.command.run({ command: 'reload-plugins' }).catch(() => undefined))
578 return { text: `${out}\n🔄 Đang nạp lại plugin (/reload-plugins)...`.trim() }
579 }
580 // the host draws the first line beside its own prefix, which would push a table's header out of line
581 if (out) return { text: argv[0] === 'list' || argv[0] === 'usage' ? `\n${out}` : out }
582
583 return { text: argv[0] === 'list' ? 'Chưa có profile nào. Tạo bằng: /profile new <tên>' : 'OK' }
584 })
585}
586