pleumrouter 0.5.42 → 0.5.43

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.
package/README.md CHANGED
@@ -45,6 +45,7 @@ SSH/CI에서는 `pleum login --device`를 쓰세요.
45
45
  | `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
46
46
  | `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
47
47
  | `pleum balance [--switch]` | 크레딧 잔액 조회 · 워크스페이스(개인/팀) 전환 |
48
+ | `pleum workspace [switch <name\|id\|personal>]` | 활성 크레딧 워크스페이스(개인/공유 팀 워크스페이스) 표시 · 전환. 초대받은 공유 워크스페이스에서 실행하면 호출은 소유자 계정으로 청구된다 |
48
49
  | `pleum usage [--by model\|provider\|api_key\|day\|tag\|user] [--days N]` | 내 지출 리포트(막대 차트) — OAuth 로그인 세션 필요 |
49
50
  | `pleum health` | 공개 게이트웨이 상태·로그인 상태 점검. DB·Redis·환율 상세는 관리자 전용 |
50
51
  | `pleum lang [code]` | 표시 언어 조회/변경 — `en`(기본)·`ko`·`ja`·`zh`·`es`. 인자 없이 TTY면 화살표로 선택, `PLEUM_LANG` 환경변수로도 덮어쓰기 가능 |
@@ -88,6 +89,7 @@ env·실행 인자 또는 일부 도구의 격리 홈(`PI_CODING_AGENT_DIR` / `C
88
89
  ## 환경변수
89
90
 
90
91
  - `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(API 호출용 기본 `https://apirouter.pleum.ai`, 브라우저 로그인 URL용 기본 `https://router.pleum.ai` 둘 다에 적용됨). 셀프호스트/스테이징용.
92
+ - `PLEUM_SITE_URL` — 브라우저 로그인 페이지 base만 덮어쓰기(`PLEUM_BASE_URL`보다 우선). API와 프론트 호스트가 다를 때 쓴다(예: API `http://localhost:8400`, 로그인 페이지 `http://localhost:3114`).
91
93
 
92
94
  ## 개발
93
95
 
package/bin/pleum.mjs CHANGED
@@ -10,6 +10,7 @@ import { wire, unwire, wireHelp, unwireHelp } from "../src/wire.mjs";
10
10
  import { run } from "../src/run.mjs";
11
11
  import { listModels } from "../src/models.mjs";
12
12
  import { balance } from "../src/balance.mjs";
13
+ import { workspace } from "../src/workspace.mjs";
13
14
  import { usage } from "../src/usage.mjs";
14
15
  import { top } from "../src/top.mjs";
15
16
  import { health } from "../src/health.mjs";
@@ -44,6 +45,7 @@ function buildHelp() {
44
45
  ["pleum models <id> --info", t("Model detail (price, perf, per-provider uptime)")],
45
46
  ["pleum top [--period weekly]", t("Model leaderboard · provider share (daily|weekly|monthly)")],
46
47
  ["pleum balance [--switch]", t("Check your credit balance")],
48
+ ["pleum workspace [switch <name|id>]", t("Show or switch the credit workspace (team study group)")],
47
49
  ["pleum usage [--by model] [--days 30]", t("Your spend report (model|provider|api_key|day|tag|user)")],
48
50
  ["pleum health", t("Check gateway connectivity and login status")],
49
51
  ["pleum lang [code]", t("Show/change display language (en|ko|ja|zh|es)")],
@@ -151,6 +153,13 @@ try {
151
153
  await balance({ interactive: isSwitch, switchOnly: isSwitch });
152
154
  break;
153
155
  }
156
+ case "workspace":
157
+ if (rest[0] === "-h" || rest[0] === "--help") {
158
+ console.log(t("Usage: pleum workspace [switch <name|id|personal>]"));
159
+ } else {
160
+ await workspace(rest);
161
+ }
162
+ break;
154
163
  case "usage":
155
164
  await usage(rest);
156
165
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.42",
3
+ "version": "0.5.43",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/balance.mjs CHANGED
@@ -59,7 +59,7 @@ export async function fetchCreditSummary() {
59
59
 
60
60
  export function buildCreditOptions({ personal, teams = [], activeContext = { type: "personal" } }) {
61
61
  const options = [];
62
- const isPersonalActive = activeContext.type === "personal" || !activeContext.org_id;
62
+ const isPersonalActive = activeContext.type === "personal";
63
63
  const personalBalanceStr = `${formatKrwCredit(personal.balance_krw)} ${t("credits")}${
64
64
  personal.balance_usd != null ? ` / $${personal.balance_usd.toFixed(4)}` : ""
65
65
  }`;
@@ -3,7 +3,7 @@
3
3
  import { t } from "./i18n.mjs";
4
4
  import { launchToolNames } from "./recipes.mjs";
5
5
 
6
- const COMMANDS = "claude codex agy antigravity opencode login logout lang launch wire unwire run models top balance usage health version update upgrade completion help";
6
+ const COMMANDS = "claude codex agy antigravity opencode login logout lang launch wire unwire run models top balance workspace usage health version update upgrade completion help";
7
7
  const TOOLS = launchToolNames().join(" ");
8
8
 
9
9
  function bash() {
package/src/config.mjs CHANGED
@@ -15,7 +15,9 @@ export const V1 = `${ROOT}/v1`;
15
15
  // 브라우저가 여는 /cli-auth 는 Next.js 프론트 도메인에만 존재한다 — API 도메인(ROOT)과 분리해야
16
16
  // 한다(과거 ROOT를 공유해서 쓰다가 apirouter로 기본값이 바뀌며 로그인이 깨진 적 있음).
17
17
  // 셀프호스트(PLEUM_BASE_URL 지정)는 같은 도메인이 프론트도 서빙한다고 가정해 ROOT를 재사용한다.
18
- export const SITE_ROOT = (overrideBase ?? "https://router.pleum.ai")
18
+ // 프론트·API 호스트가 다르면 PLEUM_SITE_URL이 최우선이다(예: API http://localhost:8400,
19
+ // 로그인 페이지 http://localhost:3114).
20
+ export const SITE_ROOT = (process.env.PLEUM_SITE_URL ?? overrideBase ?? "https://router.pleum.ai")
19
21
  .replace(/\/+$/, "")
20
22
  .replace(/\/v1$/, "");
21
23
 
@@ -206,6 +208,13 @@ export async function refreshAccessToken(cfg = readConfig()) {
206
208
  /** 활성 크레딧/워크스페이스 컨텍스트 조회 (기본값 personal) */
207
209
  export function getActiveContext(cfg = readConfig()) {
208
210
  const ctx = cfg?.active_context;
211
+ if (ctx && ctx.type === "workspace" && ctx.workspace_id) {
212
+ return {
213
+ type: "workspace",
214
+ workspace_id: ctx.workspace_id,
215
+ workspace_name: ctx.workspace_name || ctx.workspace_id,
216
+ };
217
+ }
209
218
  if (ctx && ctx.type === "team" && ctx.org_id) {
210
219
  return { type: "team", org_id: ctx.org_id, org_name: ctx.org_name || ctx.org_id };
211
220
  }
@@ -215,7 +224,13 @@ export function getActiveContext(cfg = readConfig()) {
215
224
  /** 활성 크레딧/워크스페이스 컨텍스트 저장 */
216
225
  export function setActiveContext(context, cfg = readConfig()) {
217
226
  const updated = { ...(cfg ?? {}) };
218
- if (!context || context.type === "personal" || !context.org_id) {
227
+ if (context?.type === "workspace" && context.workspace_id) {
228
+ updated.active_context = {
229
+ type: "workspace",
230
+ workspace_id: context.workspace_id,
231
+ workspace_name: context.workspace_name || null,
232
+ };
233
+ } else if (!context || context.type === "personal" || !context.org_id) {
219
234
  updated.active_context = { type: "personal" };
220
235
  } else {
221
236
  updated.active_context = {
package/src/launch.mjs CHANGED
@@ -316,9 +316,11 @@ export async function launch(tool, model, passthrough, {
316
316
  };
317
317
 
318
318
  const activeCtx = getActiveContext();
319
- const creditMsg = activeCtx.type === "team"
320
- ? `${t("Team")}: ${activeCtx.org_name}`
321
- : t("Personal");
319
+ const creditMsg = activeCtx.type === "workspace"
320
+ ? `${t("Workspace")}: ${sanitizeTerminalText(activeCtx.workspace_name)}`
321
+ : activeCtx.type === "team"
322
+ ? `${t("Team")}: ${activeCtx.org_name}`
323
+ : t("Personal");
322
324
  console.log(`ℹ ${t("Credit:")} ${creditMsg}`);
323
325
 
324
326
  const args = [...r.args(model), ...passthrough];
@@ -264,6 +264,15 @@ export const ES = {
264
264
  "by tag": "por etiqueta",
265
265
  "by user": "por usuario",
266
266
  "Failed to fetch balance ({status})": "Error al obtener el saldo ({status})",
267
+ "Workspace": "Espacio",
268
+ "Using workspace: {name}": "Usando el espacio: {name}",
269
+ "You belong to multiple workspaces. Run `pleum workspace` to choose one.": "Perteneces a varios espacios. Ejecuta `pleum workspace` para elegir uno.",
270
+ "Failed to fetch workspace context ({status})": "Error al obtener el contexto del espacio ({status})",
271
+ "Failed to switch workspace ({status})": "Error al cambiar de espacio ({status})",
272
+ "Usage: pleum workspace [switch <name|id|personal>]": "Uso: pleum workspace [switch <nombre|id|personal>]",
273
+ "No workspace matches: {query}": "Ningún espacio coincide: {query}",
274
+ "Multiple workspaces match {query}: {list}": "Varios espacios coinciden con {query}: {list}",
275
+ "Show or switch the credit workspace (team study group)": "Mostrar o cambiar el espacio de crédito (grupo de estudio del equipo)",
267
276
 
268
277
  "This install command needs a shell pipe; run it manually:": "Este comando de instalación requiere una tubería de shell; ejecútalo manualmente:",
269
278
 
@@ -264,6 +264,15 @@ export const JA = {
264
264
  "by tag": "タグ別",
265
265
  "by user": "ユーザー別",
266
266
  "Failed to fetch balance ({status})": "残高の取得に失敗しました ({status})",
267
+ "Workspace": "ワークスペース",
268
+ "Using workspace: {name}": "ワークスペースを使用: {name}",
269
+ "You belong to multiple workspaces. Run `pleum workspace` to choose one.": "複数のワークスペースに所属しています。`pleum workspace` で選択してください。",
270
+ "Failed to fetch workspace context ({status})": "ワークスペースコンテキストの取得に失敗 ({status})",
271
+ "Failed to switch workspace ({status})": "ワークスペースの切り替えに失敗 ({status})",
272
+ "Usage: pleum workspace [switch <name|id|personal>]": "使い方: pleum workspace [switch <名前|id|personal>]",
273
+ "No workspace matches: {query}": "一致するワークスペースがありません: {query}",
274
+ "Multiple workspaces match {query}: {list}": "複数のワークスペースが一致します {query}: {list}",
275
+ "Show or switch the credit workspace (team study group)": "クレジットワークスペースの表示・切り替え(チーム学習グループ)",
267
276
 
268
277
  "This install command needs a shell pipe; run it manually:": "シェルパイプが必要なインストールコマンドです。手動で実行してください:",
269
278
 
@@ -323,6 +323,15 @@ export const KO = {
323
323
  "by tag": "태그별",
324
324
  "by user": "사용자별",
325
325
  "Failed to fetch balance ({status})": "잔액 조회 실패 ({status})",
326
+ "Workspace": "워크스페이스",
327
+ "Using workspace: {name}": "워크스페이스 사용: {name}",
328
+ "You belong to multiple workspaces. Run `pleum workspace` to choose one.": "소속된 워크스페이스가 여러 개입니다. `pleum workspace` 명령으로 선택하세요.",
329
+ "Failed to fetch workspace context ({status})": "워크스페이스 컨텍스트 조회 실패 ({status})",
330
+ "Failed to switch workspace ({status})": "워크스페이스 전환 실패 ({status})",
331
+ "Usage: pleum workspace [switch <name|id|personal>]": "사용법: pleum workspace [switch <이름|id|personal>]",
332
+ "No workspace matches: {query}": "일치하는 워크스페이스가 없습니다: {query}",
333
+ "Multiple workspaces match {query}: {list}": "여러 워크스페이스가 일치합니다 {query}: {list}",
334
+ "Show or switch the credit workspace (team study group)": "크레딧 워크스페이스 표시·전환 (팀 스터디 그룹)",
326
335
 
327
336
  "This install command needs a shell pipe; run it manually:": "셸 파이프가 필요한 설치 명령입니다 — 직접 실행해 주세요:",
328
337
 
@@ -264,6 +264,15 @@ export const ZH = {
264
264
  "by tag": "按标签",
265
265
  "by user": "按用户",
266
266
  "Failed to fetch balance ({status})": "获取余额失败 ({status})",
267
+ "Workspace": "工作区",
268
+ "Using workspace: {name}": "正在使用工作区:{name}",
269
+ "You belong to multiple workspaces. Run `pleum workspace` to choose one.": "你属于多个工作区。请运行 `pleum workspace` 选择一个。",
270
+ "Failed to fetch workspace context ({status})": "获取工作区上下文失败 ({status})",
271
+ "Failed to switch workspace ({status})": "切换工作区失败 ({status})",
272
+ "Usage: pleum workspace [switch <name|id|personal>]": "用法:pleum workspace [switch <名称|id|personal>]",
273
+ "No workspace matches: {query}": "没有匹配的工作区:{query}",
274
+ "Multiple workspaces match {query}: {list}": "多个工作区匹配 {query}:{list}",
275
+ "Show or switch the credit workspace (team study group)": "显示或切换积分工作区(团队学习小组)",
267
276
 
268
277
  "This install command needs a shell pipe; run it manually:": "此安装命令需要 shell 管道,请手动运行:",
269
278
 
package/src/login.mjs CHANGED
@@ -47,17 +47,36 @@ function saveTokens({ access_token, refresh_token, expires_in }) {
47
47
  );
48
48
  }
49
49
 
50
- export async function syncCliContext(accessToken) {
50
+ export async function syncCliContext(accessToken, { fetchImpl = fetch, save = setActiveContext } = {}) {
51
51
  if (!accessToken) return;
52
52
  try {
53
- const res = await fetch(`${V1}/auth/cli-context`, {
53
+ const res = await fetchImpl(`${V1}/auth/cli-context`, {
54
54
  headers: { Authorization: `Bearer ${accessToken}` },
55
55
  });
56
- if (res.ok) {
57
- const data = await res.json();
58
- if (data?.active_context) {
59
- setActiveContext(data.active_context);
56
+ if (!res.ok) return;
57
+ const data = await res.json();
58
+ if (!data?.active_context) return;
59
+ let context = data.active_context;
60
+ const candidates = Array.isArray(data.workspace_candidates) ? data.workspace_candidates : [];
61
+ // 초대받은 멤버가 별도 조작 없이 소유자 잔액으로 실행되게 — 후보가 하나뿐이면 자동 바인딩.
62
+ if (context.type === "personal" && candidates.length === 1) {
63
+ const bound = await fetchImpl(`${V1}/auth/cli-context`, {
64
+ method: "POST",
65
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
66
+ body: JSON.stringify({ workspace_id: candidates[0].workspace_id }),
67
+ });
68
+ if (bound.ok) {
69
+ const boundData = await bound.json().catch(() => null);
70
+ if (boundData?.active_context) context = boundData.active_context;
60
71
  }
72
+ } else if (context.type === "personal" && candidates.length > 1) {
73
+ console.log(`ℹ ${t("You belong to multiple workspaces. Run `pleum workspace` to choose one.")}`);
74
+ }
75
+ save(context);
76
+ if (context.type === "workspace") {
77
+ console.log(
78
+ `✓ ${t("Using workspace: {name}", { name: sanitizeTerminalText(context.workspace_name || context.workspace_id) })}`,
79
+ );
61
80
  }
62
81
  } catch {}
63
82
  }
package/src/settings.mjs CHANGED
@@ -28,9 +28,11 @@ export function buildSettingsItems({
28
28
  activeContext = { type: "personal" },
29
29
  latestVersion = null,
30
30
  }) {
31
- const contextDesc = activeContext?.type === "team"
32
- ? `${t("Team")}: ${activeContext.org_name || activeContext.org_id}`
33
- : t("Personal Credit");
31
+ const contextDesc = activeContext?.type === "workspace"
32
+ ? `${t("Workspace")}: ${sanitizeTerminalText(activeContext.workspace_name || activeContext.workspace_id)}`
33
+ : activeContext?.type === "team"
34
+ ? `${t("Team")}: ${activeContext.org_name || activeContext.org_id}`
35
+ : t("Personal Credit");
34
36
  const updateDesc = latestVersion && isNewerVersion(version, latestVersion)
35
37
  ? `${t("Current version: {version}", { version })} → v${latestVersion} (${t("Update available")})`
36
38
  : t("Current version: {version}", { version });
package/src/wire.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { ROOT, V1, authFetch } from "./config.mjs";
4
+ import { ROOT, V1, authFetch, getActiveContext } from "./config.mjs";
5
5
  import { tomlBasicString } from "./recipes.mjs";
6
6
  import { t } from "./i18n.mjs";
7
7
 
@@ -177,7 +177,10 @@ function parseJsonBody(res) {
177
177
  export async function ensureWireKey({
178
178
  auth = authFetch,
179
179
  paths = wirePaths(),
180
+ activeContext = getActiveContext(),
180
181
  } = {}) {
182
+ const targetWorkspaceId =
183
+ activeContext?.type === "workspace" ? activeContext.workspace_id : null;
181
184
  const stored = (() => {
182
185
  try {
183
186
  return JSON.parse(readText(paths.wire) ?? "");
@@ -191,7 +194,27 @@ export async function ensureWireKey({
191
194
  const data = await parseJsonBody(listed);
192
195
  const items = Array.isArray(data.items) ? data.items : [];
193
196
  const found = items.find((item) => String(item.id) === String(stored.id));
194
- if (found && found.is_active !== false) return stored;
197
+ if (found && found.is_active !== false) {
198
+ if (
199
+ targetWorkspaceId &&
200
+ String(found.workspace_id ?? "") !== String(targetWorkspaceId)
201
+ ) {
202
+ const moved = await auth(`/workspaces/keys/${stored.id}/move`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify({ target_workspace_id: targetWorkspaceId }),
206
+ });
207
+ if (moved.ok) {
208
+ const movedRecord = { ...stored, workspace_id: targetWorkspaceId };
209
+ writeSecretFile(paths.wire, `${JSON.stringify(movedRecord, null, 2)}\n`);
210
+ return movedRecord;
211
+ }
212
+ // 이동은 keys.manage_all 보유자(소유자/관리자)만 가능하다 — 일반 멤버는
213
+ // 아래 새 키 발급으로 공간 귀속 키를 만든다(과금 오귀속보다 키 재발급이 낫다).
214
+ } else {
215
+ return stored;
216
+ }
217
+ }
195
218
  } else if (listed.status !== 404) {
196
219
  return stored;
197
220
  }
@@ -200,7 +223,10 @@ export async function ensureWireKey({
200
223
  const created = await auth("/keys", {
201
224
  method: "POST",
202
225
  headers: { "Content-Type": "application/json" },
203
- body: JSON.stringify({ name: WIRE_KEY_NAME }),
226
+ body: JSON.stringify({
227
+ name: WIRE_KEY_NAME,
228
+ ...(targetWorkspaceId ? { workspace_id: targetWorkspaceId } : {}),
229
+ }),
204
230
  });
205
231
  if (!created.ok) {
206
232
  throw new Error(t("Failed to create a desktop API key (HTTP {status})", { status: created.status }));
@@ -209,7 +235,12 @@ export async function ensureWireKey({
209
235
  if (!data.key || !data.id) {
210
236
  throw new Error(t("Failed to create a desktop API key (HTTP {status})", { status: created.status }));
211
237
  }
212
- const record = { id: data.id, key: data.key, name: data.name ?? WIRE_KEY_NAME };
238
+ const record = {
239
+ id: data.id,
240
+ key: data.key,
241
+ name: data.name ?? WIRE_KEY_NAME,
242
+ ...(targetWorkspaceId ? { workspace_id: targetWorkspaceId } : {}),
243
+ };
213
244
  writeSecretFile(paths.wire, `${JSON.stringify(record, null, 2)}\n`);
214
245
  return record;
215
246
  }
@@ -0,0 +1,98 @@
1
+ import { authFetch, getActiveContext, setActiveContext } from "./config.mjs";
2
+ import { sanitizeTerminalText, style } from "./ansi.mjs";
3
+ import { t } from "./i18n.mjs";
4
+
5
+ // 서버가 계산한 활성 컨텍스트 = CLI 세션 키의 workspace_id(과금 귀속 진실).
6
+ export async function fetchWorkspaceContext(auth = authFetch) {
7
+ const res = await auth("/auth/cli-context");
8
+ if (!res.ok) {
9
+ throw new Error(t("Failed to fetch workspace context ({status})", { status: res.status }));
10
+ }
11
+ return res.json();
12
+ }
13
+
14
+ // workspaceId=null이면 personal 복귀(서버가 키 바인딩을 리셋한다).
15
+ export async function bindWorkspaceContext(workspaceId, auth = authFetch) {
16
+ const res = await auth("/auth/cli-context", {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ workspace_id: workspaceId }),
20
+ });
21
+ if (!res.ok) {
22
+ throw new Error(t("Failed to switch workspace ({status})", { status: res.status }));
23
+ }
24
+ const data = await res.json().catch(() => ({}));
25
+ return data?.active_context ?? { type: "personal" };
26
+ }
27
+
28
+ // 후보 매칭 — 전체 UUID > UUID 접두 > 이름 부분일치. 모호하면 던진다(조용한 오바인딩 금지).
29
+ export function matchWorkspaceCandidate(candidates, query) {
30
+ const q = String(query ?? "").trim().toLowerCase();
31
+ if (!q) throw new Error(t("Usage: pleum workspace [switch <name|id|personal>]"));
32
+ let hits = candidates.filter((c) => String(c.workspace_id).toLowerCase() === q);
33
+ if (hits.length === 0) {
34
+ hits = candidates.filter((c) => String(c.workspace_id).toLowerCase().startsWith(q));
35
+ }
36
+ if (hits.length === 0) {
37
+ hits = candidates.filter((c) => String(c.name).toLowerCase().includes(q));
38
+ }
39
+ if (hits.length === 0) throw new Error(t("No workspace matches: {query}", { query }));
40
+ if (hits.length > 1) {
41
+ throw new Error(
42
+ t("Multiple workspaces match {query}: {list}", {
43
+ query,
44
+ list: hits.map((c) => c.workspace_id).join(", "),
45
+ }),
46
+ );
47
+ }
48
+ return hits[0];
49
+ }
50
+
51
+ export function workspaceLabel(context) {
52
+ if (context?.type === "workspace") {
53
+ return `${t("Workspace")}: ${sanitizeTerminalText(context.workspace_name || context.workspace_id)}`;
54
+ }
55
+ return t("Personal");
56
+ }
57
+
58
+ export function formatWorkspaceList(context, candidates = []) {
59
+ const activeId = context?.type === "workspace" ? String(context.workspace_id) : null;
60
+ const lines = [`${t("Credit workspace")}: ${workspaceLabel(context)}`];
61
+ for (const candidate of candidates) {
62
+ const mark = String(candidate.workspace_id) === activeId ? style.bold(style.plum("▸ ")) : " ";
63
+ lines.push(
64
+ `${mark}${candidate.workspace_id} ${sanitizeTerminalText(candidate.name)} (${candidate.role})`,
65
+ );
66
+ }
67
+ return lines.join("\n");
68
+ }
69
+
70
+ export async function workspace(args = [], { auth = authFetch, save = setActiveContext } = {}) {
71
+ const [sub, ...rest] = args;
72
+ if (sub !== undefined && sub !== "switch") {
73
+ console.error(t("Usage: pleum workspace [switch <name|id|personal>]"));
74
+ process.exit(1);
75
+ }
76
+
77
+ const data = await fetchWorkspaceContext(auth);
78
+ const context = data?.active_context ?? getActiveContext();
79
+
80
+ if (sub !== "switch") {
81
+ console.log(formatWorkspaceList(context, data?.workspace_candidates ?? []));
82
+ return context;
83
+ }
84
+
85
+ const query = rest[0];
86
+ if (query && query.toLowerCase() === "personal") {
87
+ const next = await bindWorkspaceContext(null, auth);
88
+ save(next);
89
+ console.log(`✓ ${t("Active credit set to: {name}", { name: t("Personal") })}`);
90
+ return next;
91
+ }
92
+
93
+ const hit = matchWorkspaceCandidate(data?.workspace_candidates ?? [], query);
94
+ const next = await bindWorkspaceContext(hit.workspace_id, auth);
95
+ save(next);
96
+ console.log(`✓ ${t("Active credit set to: {name}", { name: workspaceLabel(next) })}`);
97
+ return next;
98
+ }