@event4u/agent-switch 2.1.0 → 2.3.0

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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Managed desktop launchers (macOS): one small app per app × profile in
3
+ * `~/Applications/Agent Switch/`, which a Dock or Spotlight click can start.
4
+ *
5
+ * Each launcher is an AppleScript applet (`osacompile`) whose only line runs
6
+ * this CLI's `open <app> <profile>` — the same isolated start the app menu
7
+ * uses, including "focus the window it already has". The original app is never
8
+ * copied, patched or re-signed. A locally built applet carries no quarantine
9
+ * flag, so Gatekeeper does not prompt; swapping its icon breaks the ad-hoc
10
+ * seal, which is why the bundle is re-signed ad hoc afterwards.
11
+ *
12
+ * Every launcher made is recorded in `launchers.json`, so remove, repair and
13
+ * uninstall touch exactly those bundles.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { findApp } from "./apps.js";
21
+ import { ROOT } from "./profiles.js";
22
+ const MANIFEST = path.join(ROOT, "launchers.json");
23
+ /** macOS-only paths: built with path.posix so they are right on any host. */
24
+ export function launchersDir(home = os.homedir()) {
25
+ return path.posix.join(home, "Applications", "Agent Switch");
26
+ }
27
+ export function launcherPath(app, profile, home = os.homedir()) {
28
+ return path.posix.join(launchersDir(home), `${app.displayName} – ${profile}.app`);
29
+ }
30
+ function asLiteral(s) {
31
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
32
+ }
33
+ /** The applet's single line: run `<node> <index.js> open <app> <profile>`,
34
+ * carrying a custom AGENT_SWITCH_HOME — a Dock start has none in its env. */
35
+ export function appleScriptFor(node, index, appId, profile, home) {
36
+ const env = home ? `"AGENT_SWITCH_HOME=" & quoted form of ${asLiteral(home)} & " " & ` : "";
37
+ return (`do shell script ${env}quoted form of ${asLiteral(node)} & " " & quoted form of ${asLiteral(index)}` +
38
+ ` & " open " & quoted form of ${asLiteral(appId)} & " " & quoted form of ${asLiteral(profile)}`);
39
+ }
40
+ /** A brew/installer `bin/node` symlink that resolves to the running node — it
41
+ * survives an upgrade, the versioned path behind it does not. */
42
+ export function stableNode(execPath, candidates = ["/opt/homebrew/bin/node", "/usr/local/bin/node"], realpath = (p) => fs.realpathSync(p)) {
43
+ let running;
44
+ try {
45
+ running = realpath(execPath);
46
+ }
47
+ catch {
48
+ return execPath;
49
+ }
50
+ for (const c of candidates) {
51
+ try {
52
+ if (realpath(c) === running)
53
+ return c;
54
+ }
55
+ catch {
56
+ /* not installed there */
57
+ }
58
+ }
59
+ return execPath;
60
+ }
61
+ /** The main (non-helper) process started with exactly this `--user-data-dir`. */
62
+ export function findMainPid(psOutput, userDataDir) {
63
+ const flag = `--user-data-dir=${userDataDir}`;
64
+ for (const line of psOutput.split("\n")) {
65
+ const m = line.match(/^\s*(\d+)\s+(.*)$/);
66
+ if (!m)
67
+ continue;
68
+ const args = m[2];
69
+ const at = args.indexOf(flag);
70
+ if (at < 0 || args.includes("--type="))
71
+ continue;
72
+ const next = args[at + flag.length];
73
+ if (next === undefined || next === " ")
74
+ return Number(m[1]);
75
+ }
76
+ return null;
77
+ }
78
+ /** Brings an already running profile instance to the front. False = none runs
79
+ * (or it could not be activated), so the caller starts one. */
80
+ export function findRunning(userDataDir) {
81
+ if (process.platform !== "darwin")
82
+ return null;
83
+ const ps = spawnSync("ps", ["-axo", "pid=,args="], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
84
+ return ps.status === 0 ? findMainPid(ps.stdout, userDataDir) : null;
85
+ }
86
+ export function focusRunning(userDataDir) {
87
+ const pid = findRunning(userDataDir);
88
+ if (pid === null)
89
+ return false;
90
+ // NSRunningApplication.activate needs no Accessibility/Automation permission.
91
+ const js = `ObjC.import('AppKit'); const a = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${pid}); a.isNil() ? 'no' : (a.activateWithOptions($.NSApplicationActivateAllWindows) ? 'yes' : 'no')`;
92
+ const r = spawnSync("osascript", ["-l", "JavaScript", "-e", js], { encoding: "utf8" });
93
+ return r.status === 0 && r.stdout.trim() === "yes";
94
+ }
95
+ export function upsertEntry(entries, e) {
96
+ return [...withoutEntry(entries, e.app, e.profile), e];
97
+ }
98
+ export function withoutEntry(entries, app, profile) {
99
+ return entries.filter((x) => !(x.app === app && x.profile === profile));
100
+ }
101
+ export function entriesFor(entries, provider, profile) {
102
+ return entries.filter((x) => x.provider === provider && x.profile === profile);
103
+ }
104
+ export function readLaunchers() {
105
+ try {
106
+ const raw = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
107
+ return Array.isArray(raw) ? raw.filter((x) => x && typeof x.app === "string" && typeof x.profile === "string" && typeof x.path === "string") : [];
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ function writeLaunchers(entries) {
114
+ fs.mkdirSync(ROOT, { recursive: true });
115
+ fs.writeFileSync(MANIFEST, JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
116
+ }
117
+ /** Where the app is installed (first Spotlight hit), or null. */
118
+ export function appBundlePath(app) {
119
+ const r = spawnSync("mdfind", [`kMDItemCFBundleIdentifier == '${app.bundleId}'`], { encoding: "utf8" });
120
+ const hit = r.status === 0 ? r.stdout.split("\n").find((l) => l.endsWith(".app")) : undefined;
121
+ return hit ?? null;
122
+ }
123
+ function appIcon(bundle) {
124
+ const r = spawnSync("plutil", ["-extract", "CFBundleIconFile", "raw", path.join(bundle, "Contents", "Info.plist")], { encoding: "utf8" });
125
+ if (r.status !== 0)
126
+ return null;
127
+ const name = r.stdout.trim();
128
+ const file = path.join(bundle, "Contents", "Resources", name.endsWith(".icns") ? name : `${name}.icns`);
129
+ return fs.existsSync(file) ? file : null;
130
+ }
131
+ /** This CLI as the launcher should call it: a stable node + this index.js. */
132
+ function cliInvocation() {
133
+ return { node: stableNode(process.execPath), index: path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js") };
134
+ }
135
+ function run(cmd, args) {
136
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
137
+ if (r.status !== 0)
138
+ throw new Error(`${cmd} failed: ${(r.stderr || r.stdout).trim()}`);
139
+ }
140
+ /** Builds (or rebuilds) the launcher for app × profile and records it. */
141
+ export function createLauncher(app, profile) {
142
+ if (process.platform !== "darwin")
143
+ throw new Error("launchers are macOS-only for now.");
144
+ const bundle = appBundlePath(app);
145
+ if (!bundle)
146
+ throw new Error(`${app.displayName} is not installed.`);
147
+ const target = launcherPath(app, profile);
148
+ fs.mkdirSync(path.dirname(target), { recursive: true });
149
+ // Built next to the target, then swapped in: a failed build leaves the old one.
150
+ // The name must end in .app, or osacompile writes a bare script, not a bundle.
151
+ const tmp = path.join(path.dirname(target), `.${path.basename(target, ".app")}.building.app`);
152
+ fs.rmSync(tmp, { recursive: true, force: true });
153
+ try {
154
+ const { node, index } = cliInvocation();
155
+ run("osacompile", ["-o", tmp, "-e", appleScriptFor(node, index, app.id, profile, process.env.AGENT_SWITCH_HOME)]);
156
+ const icon = appIcon(bundle);
157
+ if (icon) {
158
+ // The applet's asset catalog would win over applet.icns and show a blank icon.
159
+ fs.rmSync(path.join(tmp, "Contents", "Resources", "Assets.car"), { force: true });
160
+ fs.copyFileSync(icon, path.join(tmp, "Contents", "Resources", "applet.icns"));
161
+ }
162
+ run("codesign", ["--force", "--sign", "-", tmp]);
163
+ fs.rmSync(target, { recursive: true, force: true });
164
+ fs.renameSync(tmp, target);
165
+ }
166
+ finally {
167
+ fs.rmSync(tmp, { recursive: true, force: true });
168
+ }
169
+ const entry = { app: app.id, provider: app.provider, profile, path: target };
170
+ writeLaunchers(upsertEntry(readLaunchers(), entry));
171
+ return entry;
172
+ }
173
+ /** Removes a recorded launcher; a path the manifest doesn't list is never deleted. */
174
+ export function removeLauncher(appId, profile) {
175
+ const entries = readLaunchers();
176
+ const hit = entries.find((x) => x.app === appId && x.profile === profile);
177
+ if (!hit)
178
+ return false;
179
+ fs.rmSync(hit.path, { recursive: true, force: true });
180
+ writeLaunchers(withoutEntry(entries, appId, profile));
181
+ return true;
182
+ }
183
+ export function removeLaunchersFor(provider, profile) {
184
+ const mine = entriesFor(readLaunchers(), provider, profile);
185
+ for (const e of mine)
186
+ removeLauncher(e.app, e.profile);
187
+ return mine.length;
188
+ }
189
+ /** A renamed profile's launchers are rebuilt under the new name (best effort:
190
+ * one that can't be rebuilt is removed rather than left pointing at nothing). */
191
+ export function renameLaunchersFor(provider, from, to) {
192
+ for (const e of entriesFor(readLaunchers(), provider, from)) {
193
+ removeLauncher(e.app, e.profile);
194
+ const app = findApp(e.app);
195
+ if (!app)
196
+ continue;
197
+ try {
198
+ createLauncher(app, to);
199
+ }
200
+ catch {
201
+ /* app gone — nothing to rebuild */
202
+ }
203
+ }
204
+ }
205
+ /** Rebuilds every recorded launcher — after a move of this CLI or a node upgrade. */
206
+ export function repairLaunchers() {
207
+ const rebuilt = [];
208
+ const failed = [];
209
+ for (const e of readLaunchers()) {
210
+ const app = findApp(e.app);
211
+ try {
212
+ if (!app)
213
+ throw new Error(`unknown app ${e.app}`);
214
+ rebuilt.push(createLauncher(app, e.profile));
215
+ }
216
+ catch (err) {
217
+ failed.push({ entry: e, reason: err instanceof Error ? err.message : String(err) });
218
+ }
219
+ }
220
+ return { rebuilt, failed };
221
+ }
222
+ export function removeAllLaunchers() {
223
+ const entries = readLaunchers();
224
+ for (const e of entries)
225
+ fs.rmSync(e.path, { recursive: true, force: true });
226
+ writeLaunchers([]);
227
+ return entries.length;
228
+ }
package/dist/locks.js CHANGED
@@ -87,3 +87,64 @@ export async function withProperLock(target, fn, timeoutMs = DEFAULT_TIMEOUT_MS)
87
87
  }
88
88
  }
89
89
  }
90
+ /**
91
+ * Synchronous twin of {@link withProperLock} for agent-switch's own small
92
+ * stores (pools, protection): one read-modify-write at a time across every
93
+ * CLI and GUI process, so two writers cannot drop each other's change. Same
94
+ * directory-lock protocol and staleness rule; short waits block the thread
95
+ * (the critical section is a JSON read and write).
96
+ */
97
+ export function withFileLockSync(target, fn, timeoutMs = DEFAULT_TIMEOUT_MS) {
98
+ const lockDir = lockDirFor(target);
99
+ const deadline = Date.now() + timeoutMs;
100
+ const nap = new Int32Array(new SharedArrayBuffer(4));
101
+ fs.mkdirSync(path.dirname(target), { recursive: true });
102
+ for (;;) {
103
+ try {
104
+ fs.mkdirSync(lockDir);
105
+ break;
106
+ }
107
+ catch (err) {
108
+ // Windows answers EPERM while another process is removing the lock dir.
109
+ const busy = err?.code === "EEXIST" || (process.platform === "win32" && err?.code === "EPERM");
110
+ if (!busy)
111
+ throw err;
112
+ if (err.code === "EPERM") {
113
+ if (Date.now() > deadline)
114
+ throw err;
115
+ Atomics.wait(nap, 0, 0, 10);
116
+ continue;
117
+ }
118
+ let stale = false;
119
+ try {
120
+ stale = Date.now() - fs.statSync(lockDir).mtimeMs > STALENESS_MS;
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ if (stale) {
126
+ try {
127
+ fs.rmdirSync(lockDir);
128
+ }
129
+ catch {
130
+ /* raced with another taker — loop */
131
+ }
132
+ continue;
133
+ }
134
+ if (Date.now() > deadline)
135
+ throw new Error(`timed out waiting for the lock at ${lockDir}`);
136
+ Atomics.wait(nap, 0, 0, 10 + Math.floor(Math.random() * 15));
137
+ }
138
+ }
139
+ try {
140
+ return fn();
141
+ }
142
+ finally {
143
+ try {
144
+ fs.rmdirSync(lockDir);
145
+ }
146
+ catch {
147
+ /* already gone */
148
+ }
149
+ }
150
+ }
package/dist/mappings.js CHANGED
@@ -12,6 +12,9 @@
12
12
  * canonicalized) paths; values are `{ provider, name }`. A path may map a
13
13
  * different profile per provider, so the stored value is keyed by provider:
14
14
  * `{ "<path>": { claude: "<name>", codex: "<name>", ... } }`.
15
+ *
16
+ * A rule can be paused without losing it: `paused` lists `{ "<path>": [provider] }`
17
+ * next to `mappings`, and resolution skips a paused rule as if it were absent.
15
18
  */
16
19
  import * as fs from "node:fs";
17
20
  import * as path from "node:path";
@@ -39,14 +42,30 @@ export function normalizePath(p) {
39
42
  }
40
43
  return canonicalizeWin32(resolved);
41
44
  }
42
- export function loadMappings() {
43
- let data;
45
+ function readFile() {
44
46
  try {
45
- data = JSON.parse(fs.readFileSync(MAPPINGS_FILE, "utf8"));
47
+ return JSON.parse(fs.readFileSync(MAPPINGS_FILE, "utf8"));
46
48
  }
47
49
  catch {
50
+ return null;
51
+ }
52
+ }
53
+ function loadPaused() {
54
+ const raw = readFile()?.paused;
55
+ if (!raw || typeof raw !== "object")
48
56
  return {};
57
+ const out = {};
58
+ for (const [key, value] of Object.entries(raw)) {
59
+ if (Array.isArray(value))
60
+ out[key] = value.filter((v) => typeof v === "string");
49
61
  }
62
+ return out;
63
+ }
64
+ const isPaused = (paused, key, providerId) => !!paused[key]?.includes(providerId);
65
+ export function loadMappings() {
66
+ const data = readFile();
67
+ if (!data)
68
+ return {};
50
69
  const raw = data && typeof data.mappings === "object" ? data.mappings : {};
51
70
  const out = {};
52
71
  for (const [key, value] of Object.entries(raw)) {
@@ -58,11 +77,29 @@ export function loadMappings() {
58
77
  }
59
78
  return out;
60
79
  }
61
- function save(mappings) {
80
+ /** Write mappings; paused flags of rules that no longer exist are dropped. */
81
+ function save(mappings, paused = loadPaused()) {
62
82
  ensureRoot();
63
- fs.writeFileSync(MAPPINGS_FILE, JSON.stringify({ schema: 2, mappings }, null, 2) + "\n", {
64
- mode: 0o600,
65
- });
83
+ const kept = {};
84
+ for (const [key, providers] of Object.entries(paused)) {
85
+ const live = providers.filter((p) => mappings[key]?.[p]);
86
+ if (live.length > 0)
87
+ kept[key] = live;
88
+ }
89
+ const body = Object.keys(kept).length > 0 ? { schema: 2, mappings, paused: kept } : { schema: 2, mappings };
90
+ fs.writeFileSync(MAPPINGS_FILE, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 });
91
+ }
92
+ /** Pause or resume one folder rule. False when the folder has no rule for the provider. */
93
+ export function setPaused(dir, providerId, on) {
94
+ const key = normalizePath(dir);
95
+ const mappings = loadMappings();
96
+ if (!mappings[key]?.[providerId])
97
+ return false;
98
+ const paused = loadPaused();
99
+ const rest = (paused[key] ?? []).filter((p) => p !== providerId);
100
+ paused[key] = on ? [...rest, providerId] : rest;
101
+ save(mappings, paused);
102
+ return true;
66
103
  }
67
104
  export function setMapping(dir, providerId, name) {
68
105
  const key = normalizePath(dir);
@@ -93,10 +130,11 @@ export function removeMapping(dir, providerId) {
93
130
  /** Nearest mapped ancestor of `dir` (inclusive) for a provider; null if none. */
94
131
  export function resolveMapping(dir, providerId) {
95
132
  const mappings = loadMappings();
133
+ const paused = loadPaused();
96
134
  let current = normalizePath(dir);
97
135
  for (;;) {
98
136
  const entry = mappings[current];
99
- if (entry && entry[providerId])
137
+ if (entry && entry[providerId] && !isPaused(paused, current, providerId))
100
138
  return { path: current, name: entry[providerId] };
101
139
  const parent = path.dirname(current);
102
140
  if (parent === current)
@@ -123,9 +161,10 @@ export function pruneMappings(providerId, name) {
123
161
  /** All mappings as flat rows for display. */
124
162
  export function mappingRows() {
125
163
  const rows = [];
164
+ const paused = loadPaused();
126
165
  for (const [key, entry] of Object.entries(loadMappings())) {
127
166
  for (const [provider, name] of Object.entries(entry)) {
128
- rows.push({ path: key, provider: provider, name: name });
167
+ rows.push({ path: key, provider: provider, name: name, paused: isPaused(paused, key, provider) });
129
168
  }
130
169
  }
131
170
  return rows.sort((a, b) => a.path.localeCompare(b.path) || a.provider.localeCompare(b.provider));
package/dist/pools.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Pools — explicit, user-created groups of profiles that a folder rule may point
3
+ * at (`map pool:<id> [dir]`). Additive and opt-in: the store starts empty, an
4
+ * upgrade creates nothing, and labels (Work/Personal/Other) and
5
+ * `autoSwitch.tag` are never read or changed here. Membership is many-to-many
6
+ * and keyed by `provider/name`; a pool may hold profiles of several providers,
7
+ * and a lookup only ever considers members of the provider being started.
8
+ */
9
+ import { withFileLockSync } from "./locks.js";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { ROOT, ensureRoot, labelFor, listAllProfiles, PROFILE_LABELS } from "./profiles.js";
13
+ import { isProviderId } from "./providers.js";
14
+ export const POOLS_FILE = path.join(ROOT, "pools.json");
15
+ export const POOL_PREFIX = "pool:";
16
+ export function isPoolRef(v) {
17
+ return v.startsWith(POOL_PREFIX);
18
+ }
19
+ export function poolIdOf(ref) {
20
+ return ref.slice(POOL_PREFIX.length);
21
+ }
22
+ /** `claude/work` → `{ provider, name }`, or null when it is not a member key. */
23
+ export function parseMember(v) {
24
+ const m = /^([a-z]+)\/([A-Za-z0-9_-]+)$/.exec(v);
25
+ if (!m || !isProviderId(m[1]))
26
+ return null;
27
+ return { provider: m[1], name: m[2] };
28
+ }
29
+ export function slugify(name) {
30
+ return name
31
+ .toLowerCase()
32
+ .normalize("NFKD")
33
+ .replace(/[^a-z0-9]+/g, "-")
34
+ .replace(/^-+|-+$/g, "")
35
+ .slice(0, 40);
36
+ }
37
+ export function readPools(file = POOLS_FILE) {
38
+ try {
39
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
40
+ if (!Array.isArray(raw?.pools))
41
+ return [];
42
+ return raw.pools
43
+ .filter((p) => p && typeof p.id === "string" && typeof p.name === "string")
44
+ .map((p) => ({
45
+ id: p.id,
46
+ name: p.name,
47
+ members: Array.isArray(p.members) ? p.members.filter((m) => typeof m === "string" && parseMember(m)) : [],
48
+ createdAt: typeof p.createdAt === "string" ? p.createdAt : "",
49
+ }));
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ }
55
+ function save(pools, file) {
56
+ if (file === POOLS_FILE)
57
+ ensureRoot();
58
+ const tmp = `${file}.${process.pid}.tmp`;
59
+ fs.writeFileSync(tmp, JSON.stringify({ schema: 1, pools }, null, 2) + "\n", { mode: 0o600 });
60
+ fs.renameSync(tmp, file);
61
+ }
62
+ export function findPool(id, file = POOLS_FILE) {
63
+ return readPools(file).find((p) => p.id === id) ?? null;
64
+ }
65
+ function mustFind(pools, id) {
66
+ const p = pools.find((x) => x.id === id);
67
+ if (!p)
68
+ throw new Error(`no pool "${id}" (pools: ${pools.map((x) => x.id).join(", ") || "none"})`);
69
+ return p;
70
+ }
71
+ export function createPool(name, file = POOLS_FILE, now = new Date()) {
72
+ return withFileLockSync(file, () => {
73
+ const display = name.trim();
74
+ const id = slugify(display);
75
+ if (!id)
76
+ throw new Error("pool name needs at least one letter or digit");
77
+ const pools = readPools(file);
78
+ if (pools.some((p) => p.id === id))
79
+ throw new Error(`a pool "${id}" already exists`);
80
+ const pool = { id, name: display, members: [], createdAt: now.toISOString() };
81
+ save([...pools, pool], file);
82
+ return pool;
83
+ });
84
+ }
85
+ /** Renames the display name only — the id stays, so folder rules keep working. */
86
+ export function renamePool(id, name, file = POOLS_FILE) {
87
+ return withFileLockSync(file, () => {
88
+ const pools = readPools(file);
89
+ const p = mustFind(pools, id);
90
+ if (!name.trim())
91
+ throw new Error("pool name must not be empty");
92
+ p.name = name.trim();
93
+ save(pools, file);
94
+ return p;
95
+ });
96
+ }
97
+ export function deletePool(id, file = POOLS_FILE) {
98
+ return withFileLockSync(file, () => {
99
+ const pools = readPools(file);
100
+ mustFind(pools, id);
101
+ save(pools.filter((p) => p.id !== id), file);
102
+ });
103
+ }
104
+ export function addPoolMember(id, member, file = POOLS_FILE) {
105
+ return withFileLockSync(file, () => {
106
+ if (!parseMember(member))
107
+ throw new Error(`"${member}" is not a profile key (expected provider/name, e.g. claude/work)`);
108
+ const pools = readPools(file);
109
+ const p = mustFind(pools, id);
110
+ if (!p.members.includes(member))
111
+ p.members.push(member);
112
+ save(pools, file);
113
+ return p;
114
+ });
115
+ }
116
+ export function removePoolMember(id, member, file = POOLS_FILE) {
117
+ return withFileLockSync(file, () => {
118
+ const pools = readPools(file);
119
+ const p = mustFind(pools, id);
120
+ p.members = p.members.filter((m) => m !== member);
121
+ save(pools, file);
122
+ return p;
123
+ });
124
+ }
125
+ /** Keep memberships pointing at a profile across a rename / drop them on remove. */
126
+ export function renamePoolMember(from, to, file = POOLS_FILE) {
127
+ return withFileLockSync(file, () => {
128
+ const pools = readPools(file);
129
+ let changed = false;
130
+ for (const p of pools) {
131
+ if (!p.members.includes(from))
132
+ continue;
133
+ p.members = p.members.filter((m) => m !== from);
134
+ if (to && !p.members.includes(to))
135
+ p.members.push(to);
136
+ changed = true;
137
+ }
138
+ if (changed)
139
+ save(pools, file);
140
+ });
141
+ }
142
+ /**
143
+ * "Suggest from tags": one proposed pool per label in use. A preview only —
144
+ * nothing is stored until the user confirms (`pool suggest --confirm`), and a
145
+ * proposal whose id already exists is marked so it is never overwritten.
146
+ */
147
+ export function suggestPoolsFromLabels(profiles = listAllProfiles(), label = labelFor, existing = readPools()) {
148
+ const out = [];
149
+ for (const l of PROFILE_LABELS) {
150
+ const members = profiles.filter((p) => label(p.provider, p.name) === l).map((p) => `${p.provider}/${p.name}`);
151
+ if (members.length === 0)
152
+ continue;
153
+ const id = slugify(l);
154
+ out.push({ id, name: l, members, exists: existing.some((p) => p.id === id) });
155
+ }
156
+ return out;
157
+ }