agent-toggle 0.1.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,136 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { applyEdits, modify, parse } from "jsonc-parser";
5
+ import { t } from "./i18n.js";
6
+ export const HOME = os.homedir();
7
+ /** agent-toggle's own data: stash, groups, backups, preferences. */
8
+ export const STATE_DIR = path.join(HOME, ".agent-toggle");
9
+ export const BACKUP_DIR = path.join(STATE_DIR, "backups");
10
+ export const state = { dryRun: false, changes: [] };
11
+ export const home = (...p) => path.join(HOME, ...p);
12
+ /** Path comparison key: case-insensitive on Windows only (Linux file systems are case-sensitive). */
13
+ export const pathKey = (p) => {
14
+ const r = path.resolve(p).replace(/\\/g, "/");
15
+ return process.platform === "win32" ? r.toLowerCase() : r;
16
+ };
17
+ function readText(file) {
18
+ try {
19
+ return fs.readFileSync(file, "utf8").replace(/^/, "");
20
+ }
21
+ catch (e) {
22
+ if (e.code === "ENOENT")
23
+ return undefined;
24
+ throw e;
25
+ }
26
+ }
27
+ /** Reads JSON or JSONC (comments and trailing commas allowed). */
28
+ export function readJson(file, fallback = {}) {
29
+ const raw = readText(file);
30
+ if (raw === undefined || !raw.trim())
31
+ return structuredClone(fallback);
32
+ const errors = [];
33
+ const data = parse(raw, errors, { allowTrailingComma: true, disallowComments: false });
34
+ if (errors.length && (data === undefined || typeof data !== "object"))
35
+ throw new Error(t("err.badJson", { file }));
36
+ return data ?? structuredClone(fallback);
37
+ }
38
+ const backedUp = new Set();
39
+ /** Backs the file up once per run, before the first write. */
40
+ function backup(file) {
41
+ if (backedUp.has(file) || !fs.existsSync(file))
42
+ return;
43
+ fs.mkdirSync(BACKUP_DIR, { recursive: true });
44
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
45
+ const name = file.replace(/[:\\/]+/g, "_").replace(/^_+/, "");
46
+ fs.copyFileSync(file, path.join(BACKUP_DIR, `${stamp}__${name}`));
47
+ backedUp.add(file);
48
+ // Keep only the 20 most recent copies of each file.
49
+ const copies = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith(`__${name}`)).sort();
50
+ for (const old of copies.slice(0, -20))
51
+ fs.rmSync(path.join(BACKUP_DIR, old), { force: true });
52
+ }
53
+ export function writeText(file, text, what) {
54
+ state.changes.push(what);
55
+ if (state.dryRun)
56
+ return;
57
+ backup(file);
58
+ fs.mkdirSync(path.dirname(file), { recursive: true });
59
+ const tmp = `${file}.tmp-${process.pid}`;
60
+ fs.writeFileSync(tmp, text, "utf8");
61
+ fs.renameSync(tmp, file);
62
+ }
63
+ /** Rewrites the whole file (plain JSON, no comments). */
64
+ export function writeJson(file, data, what) {
65
+ writeText(file, JSON.stringify(data, null, 2) + "\n", what);
66
+ }
67
+ /**
68
+ * Edits precise paths while keeping comments and formatting
69
+ * (essential for .jsonc). value undefined = removal.
70
+ */
71
+ export function editJson(file, what, ops) {
72
+ let text = readText(file) ?? "{}\n";
73
+ const indent = /\n(\t| +)"/.exec(text)?.[1] ?? " ";
74
+ const opts = { formattingOptions: { insertSpaces: !indent.startsWith("\t"), tabSize: indent.length, eol: text.includes("\r\n") ? "\r\n" : "\n" } };
75
+ const tree = () => parse(text, [], { allowTrailingComma: true }) ?? {};
76
+ const empty = (o) => !!o && typeof o === "object" && !Array.isArray(o) && !Object.keys(o).length;
77
+ for (const op of ops) {
78
+ const before = tree();
79
+ text = applyEdits(text, modify(text, op.path, op.value, opts));
80
+ if (op.value !== undefined)
81
+ continue;
82
+ // A removal that empties a parent object removes the parent too (unless it was already empty).
83
+ for (let d = op.path.length - 1; d > 0; d--) {
84
+ const parent = op.path.slice(0, d);
85
+ if (!empty(getPath(tree(), parent)) || empty(getPath(before, parent)))
86
+ break;
87
+ text = applyEdits(text, modify(text, parent, undefined, opts));
88
+ }
89
+ }
90
+ writeText(file, text, what);
91
+ }
92
+ export function getPath(data, p) {
93
+ return p.reduce((o, k) => (o == null ? undefined : o[k]), data);
94
+ }
95
+ /** Moves a file or a link (symlinks/junctions included) without following its target. */
96
+ export function moveEntry(from, to, what) {
97
+ state.changes.push(what);
98
+ if (state.dryRun)
99
+ return;
100
+ if (fs.existsSync(to))
101
+ throw new Error(t("err.destExists", { path: to }));
102
+ fs.mkdirSync(path.dirname(to), { recursive: true });
103
+ fs.renameSync(from, to);
104
+ }
105
+ export function isLink(p) {
106
+ try {
107
+ return fs.lstatSync(p).isSymbolicLink();
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ export function listDir(dir) {
114
+ try {
115
+ return fs.readdirSync(dir, { withFileTypes: true });
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ }
121
+ export function exists(p) {
122
+ return fs.existsSync(p);
123
+ }
124
+ export function toggleInList(list, name, present) {
125
+ const set = new Set(list ?? []);
126
+ if (present)
127
+ set.add(name);
128
+ else
129
+ set.delete(name);
130
+ return [...set];
131
+ }
132
+ /** "<what> → enabled/disabled (<where>)": the standard change message. */
133
+ export function toggled(what, enabled, where) {
134
+ const s = t(enabled ? "state.enabled" : "state.disabled");
135
+ return where ? t("change.toggleAt", { what, state: s, where }) : t("change.toggle", { what, state: s });
136
+ }
@@ -0,0 +1,128 @@
1
+ /*
2
+ * CLI logo: figlet "ANSI Shadow" art with a diagonal gradient, #C77DFF (top-left)
3
+ * to #3A86FF (bottom-right). Colors are interpolated in OKLab (perceptually even
4
+ * steps) and the angle accounts for terminal cells being about twice as tall
5
+ * as they are wide, so the gradient runs at a true 45°. Shadow glyphs
6
+ * (╗ ║ ═ …) are drawn slightly darker to give the letters some depth.
7
+ * Generated by ascii-logo: do not edit the WIDE/STACKED arrays by hand.
8
+ */
9
+ const WIDE = [
10
+ " █████╗ ██████╗ ████████╗ ██████╗ ██████╗ ██████╗ ██╗ ███████╗",
11
+ "██╔══██╗██╔════╝ ╚══██╔══╝██╔═══██╗██╔════╝ ██╔════╝ ██║ ██╔════╝",
12
+ "███████║██║ ███╗ ██║ ██║ ██║██║ ███╗██║ ███╗██║ █████╗",
13
+ "██╔══██║██║ ██║ ██║ ██║ ██║██║ ██║██║ ██║██║ ██╔══╝",
14
+ "██║ ██║╚██████╔╝ ██║ ╚██████╔╝╚██████╔╝╚██████╔╝███████╗███████╗",
15
+ "╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝"
16
+ ];
17
+ const STACKED = [
18
+ " █████╗ ██████╗",
19
+ "██╔══██╗██╔════╝",
20
+ "███████║██║ ███╗",
21
+ "██╔══██║██║ ██║",
22
+ "██║ ██║╚██████╔╝",
23
+ "╚═╝ ╚═╝ ╚═════╝",
24
+ "",
25
+ "████████╗ ██████╗ ██████╗ ██████╗ ██╗ ███████╗",
26
+ "╚══██╔══╝██╔═══██╗██╔════╝ ██╔════╝ ██║ ██╔════╝",
27
+ " ██║ ██║ ██║██║ ███╗██║ ███╗██║ █████╗",
28
+ " ██║ ██║ ██║██║ ██║██║ ██║██║ ██╔══╝",
29
+ " ██║ ╚██████╔╝╚██████╔╝╚██████╔╝███████╗███████╗",
30
+ " ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝"
31
+ ];
32
+ const PLAIN = "AG TOGGLE";
33
+ /** Gradient stops, sRGB. */
34
+ export const FROM = [199, 125, 255]; // #C77DFF
35
+ export const TO = [58, 134, 255]; // #3A86FF
36
+ /** Lightness factor of shadow glyphs (1 = same as the letters). */
37
+ const SHADOW_DIM = 0.72;
38
+ const toLinear = (c) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
39
+ const toSrgb = (c) => Math.round(255 * Math.min(1, Math.max(0, c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)));
40
+ function rgbToOklab([r, g, b]) {
41
+ const [lr, lg, lb] = [toLinear(r), toLinear(g), toLinear(b)];
42
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
43
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
44
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
45
+ return [
46
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
47
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
48
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
49
+ ];
50
+ }
51
+ function oklabToRgb([L, a, b]) {
52
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
53
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
54
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
55
+ return [
56
+ toSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
57
+ toSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
58
+ toSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
59
+ ];
60
+ }
61
+ const A = rgbToOklab(FROM);
62
+ const B = rgbToOklab(TO);
63
+ /** Color at position t ∈ [0, 1]; dim < 1 darkens (OKLab lightness) for shadow glyphs. */
64
+ export function gradientAt(t, dim = 1) {
65
+ const k = Math.min(1, Math.max(0, t));
66
+ return oklabToRgb([(A[0] + (B[0] - A[0]) * k) * dim, A[1] + (B[1] - A[1]) * k, A[2] + (B[2] - A[2]) * k]);
67
+ }
68
+ /** Terminal color depth, from the usual environment hints. */
69
+ export function colorDepth(stream = process.stdout) {
70
+ const env = process.env;
71
+ if ("NO_COLOR" in env || env.TERM === "dumb" || !stream.isTTY)
72
+ return "none";
73
+ if ("FORCE_COLOR" in env && env.FORCE_COLOR === "0")
74
+ return "none";
75
+ if (/^(truecolor|24bit)$/i.test(env.COLORTERM ?? "") || env.WT_SESSION || env.TERM_PROGRAM === "vscode" ||
76
+ env.TERM_PROGRAM === "iTerm.app" || env.TERM_PROGRAM === "WezTerm" || /-direct$|kitty|alacritty/.test(env.TERM ?? "") ||
77
+ process.platform === "win32")
78
+ return "truecolor";
79
+ return "256";
80
+ }
81
+ /** Nearest xterm-256 color (6×6×6 cube or grey ramp). */
82
+ function to256([r, g, b]) {
83
+ const cube = (c) => (c < 48 ? 0 : c < 115 ? 1 : Math.floor((c - 35) / 40));
84
+ const levels = [0, 95, 135, 175, 215, 255];
85
+ const [cr, cg, cb] = [cube(r), cube(g), cube(b)];
86
+ const cubeDist = (levels[cr] - r) ** 2 + (levels[cg] - g) ** 2 + (levels[cb] - b) ** 2;
87
+ const grey = Math.min(23, Math.max(0, Math.round(((r + g + b) / 3 - 8) / 10)));
88
+ const gv = 8 + grey * 10;
89
+ const greyDist = (gv - r) ** 2 + (gv - g) ** 2 + (gv - b) ** 2;
90
+ return greyDist < cubeDist ? 232 + grey : 16 + 36 * cr + 6 * cg + cb;
91
+ }
92
+ const SHADOW = /[╔╗╚╝═║]/;
93
+ /** Paints an art block with the diagonal gradient. */
94
+ export function paint(lines, depth) {
95
+ if (depth === "none")
96
+ return lines.join("\n");
97
+ const h = lines.length;
98
+ const w = Math.max(...lines.map((l) => l.length));
99
+ // Cells are ~2× taller than wide: weighting rows by 2 keeps the angle at 45°.
100
+ const span = Math.max(1, w - 1 + 2 * (h - 1));
101
+ return lines.map((line, y) => {
102
+ let out = "";
103
+ let last = "";
104
+ for (let x = 0; x < line.length; x++) {
105
+ const ch = line[x];
106
+ if (ch === " ") {
107
+ out += ch;
108
+ continue;
109
+ }
110
+ const rgb = gradientAt((x + 2 * y) / span, SHADOW.test(ch) ? SHADOW_DIM : 1);
111
+ const code = depth === "truecolor" ? `38;2;${rgb[0]};${rgb[1]};${rgb[2]}` : `38;5;${to256(rgb)}`;
112
+ if (code !== last)
113
+ out += `\x1b[${code}m`;
114
+ last = code;
115
+ out += ch;
116
+ }
117
+ return out + "\x1b[0m";
118
+ }).join("\n");
119
+ }
120
+ /** Logo that fits the terminal width: wide, stacked, then plain text. */
121
+ export function logo(columns = process.stdout.columns ?? 80, depth = colorDepth()) {
122
+ const fits = (ls) => Math.max(...ls.map((l) => l.length)) <= columns;
123
+ if (fits(WIDE))
124
+ return paint(WIDE, depth);
125
+ if (fits(STACKED))
126
+ return paint(STACKED, depth);
127
+ return paint([PLAIN], depth);
128
+ }
@@ -0,0 +1,21 @@
1
+ import path from "node:path";
2
+ import { pathKey, readJson, STATE_DIR, writeJson } from "./jsonio.js";
3
+ export const stashFile = path.join(STATE_DIR, "stash.json");
4
+ export function readStash() {
5
+ const s = readJson(stashFile, { entries: [] });
6
+ return { entries: s.entries ?? [] };
7
+ }
8
+ export function stashed(agent, file) {
9
+ const norm = pathKey(file);
10
+ return readStash().entries.filter((e) => e.agent === agent && pathKey(e.file) === norm);
11
+ }
12
+ export function updateStash(what, fn) {
13
+ const s = readStash();
14
+ fn(s);
15
+ writeJson(stashFile, s, what);
16
+ }
17
+ export function sameEntry(a, b) {
18
+ return a.agent === b.agent && pathKey(a.file) === pathKey(b.file) &&
19
+ a.kind === b.kind && JSON.stringify(a.path) === JSON.stringify(b.path) &&
20
+ (b.value === undefined || JSON.stringify(a.value) === JSON.stringify(b.value));
21
+ }
@@ -0,0 +1,138 @@
1
+ import fs from "node:fs";
2
+ import { parse } from "smol-toml";
3
+ import { writeText } from "./jsonio.js";
4
+ import { t } from "./i18n.js";
5
+ export function readToml(file) {
6
+ try {
7
+ return parse(fs.readFileSync(file, "utf8").replace(/^/, ""));
8
+ }
9
+ catch (e) {
10
+ if (e.code === "ENOENT")
11
+ return {};
12
+ throw new Error(t("err.badToml", { file, msg: e.message }));
13
+ }
14
+ }
15
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
+ const keyAlt = (k) => `(?:${esc(k)}|"${esc(k)}"|'${esc(k)}')`;
17
+ const bare = (k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k));
18
+ function headerRe(table, array = false) {
19
+ const [o, c] = array ? ["\\[\\[", "\\]\\]"] : ["\\[", "\\]"];
20
+ return new RegExp(`^\\s*${o}\\s*${table.map(keyAlt).join("\\s*\\.\\s*")}\\s*${c}\\s*(#.*)?$`);
21
+ }
22
+ const isHeader = (l) => /^\s*\[/.test(l);
23
+ function fmt(v) {
24
+ if (Array.isArray(v))
25
+ return `[${v.map((x) => JSON.stringify(x)).join(", ")}]`;
26
+ return typeof v === "string" ? JSON.stringify(v) : String(v);
27
+ }
28
+ function load(file) {
29
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
30
+ return { eol: text.includes("\r\n") ? "\r\n" : "\n", lines: text ? text.split(/\r?\n/) : [] };
31
+ }
32
+ /**
33
+ * Sets a key in a table (table [] = file root); value undefined removes it.
34
+ * The table is created at the end of the file when missing.
35
+ */
36
+ export function setTomlKey(file, table, key, value, what) {
37
+ const { eol, lines } = load(file);
38
+ const line = value === undefined ? undefined : `${bare(key)} = ${fmt(value)}`;
39
+ let start = table.length ? lines.findIndex((l) => headerRe(table).test(l)) : -1;
40
+ if (table.length && start < 0) {
41
+ if (line === undefined)
42
+ return;
43
+ while (lines.length && !lines.at(-1).trim())
44
+ lines.pop();
45
+ lines.push("", `[${table.map(bare).join(".")}]`, line, "");
46
+ return writeText(file, withEol(lines, eol), what);
47
+ }
48
+ let end = lines.findIndex((l, i) => i > start && isHeader(l));
49
+ if (end < 0)
50
+ end = lines.length;
51
+ const keyRe = new RegExp(`^\\s*${keyAlt(key)}\\s*=`);
52
+ const at = lines.findIndex((l, i) => i > start && i < end && keyRe.test(l));
53
+ if (at >= 0) {
54
+ if (line === undefined) {
55
+ lines.splice(at, 1);
56
+ // Table left empty: remove its header too (and the blank line before it).
57
+ const body = lines.slice(start + 1, end - 1);
58
+ if (table.length && body.every((l) => !l.trim()))
59
+ lines.splice(start - (start > 0 && !lines[start - 1].trim() ? 1 : 0), end - 1 - start + (start > 0 && !lines[start - 1].trim() ? 1 : 0));
60
+ }
61
+ else
62
+ lines[at] = line;
63
+ }
64
+ else if (line !== undefined) {
65
+ // At the root: before the first table; otherwise right below the header.
66
+ lines.splice(table.length ? start + 1 : end, 0, line);
67
+ }
68
+ else
69
+ return;
70
+ writeText(file, withEol(lines, eol), what);
71
+ }
72
+ /** Successive [[table]] blocks: start/end indexes and parsed content. */
73
+ function arrayBlocks(lines, table) {
74
+ const out = [];
75
+ lines.forEach((l, i) => {
76
+ if (!headerRe(table, true).test(l))
77
+ return;
78
+ let end = lines.findIndex((x, j) => j > i && isHeader(x));
79
+ if (end < 0)
80
+ end = lines.length;
81
+ let data = {};
82
+ try {
83
+ data = parse(lines.slice(i + 1, end).join("\n"));
84
+ }
85
+ catch { /* unreadable block: ignored */ }
86
+ out.push({ start: i, end, data });
87
+ });
88
+ return out;
89
+ }
90
+ export function addArrayTable(file, table, entries, what) {
91
+ const { eol, lines } = load(file);
92
+ while (lines.length && !lines.at(-1).trim())
93
+ lines.pop();
94
+ lines.push("", `[[${table.map(bare).join(".")}]]`, ...Object.entries(entries).map(([k, v]) => `${bare(k)} = ${fmt(v)}`), "");
95
+ writeText(file, withEol(lines, eol), what);
96
+ }
97
+ export function removeArrayTables(file, table, match, what) {
98
+ const { eol, lines } = load(file);
99
+ const blocks = arrayBlocks(lines, table).filter((b) => match(b.data));
100
+ if (!blocks.length)
101
+ return;
102
+ for (const b of blocks.reverse()) {
103
+ let end = b.end;
104
+ while (end - 1 > b.start && !lines[end - 1].trim())
105
+ end--;
106
+ lines.splice(b.start, end - b.start + (lines[end] !== undefined && !lines[end].trim() ? 1 : 0));
107
+ }
108
+ writeText(file, withEol(lines, eol), what);
109
+ }
110
+ /** Always a final newline, like editors and the agents' own CLIs do. */
111
+ function withEol(lines, eol) {
112
+ const out = lines.join(eol);
113
+ return out.endsWith(eol) ? out : out + eol;
114
+ }
115
+ /**
116
+ * Sets (or removes) a key in the [[table]] block matching a predicate
117
+ * (e.g. the [[mcp_servers]] block whose name = "x").
118
+ */
119
+ export function setArrayTableKey(file, table, match, key, value, what) {
120
+ const { eol, lines } = load(file);
121
+ const block = arrayBlocks(lines, table).find((b) => match(b.data));
122
+ if (!block)
123
+ return;
124
+ const keyRe = new RegExp(`^\\s*${keyAlt(key)}\\s*=`);
125
+ const at = lines.findIndex((l, i) => i > block.start && i < block.end && keyRe.test(l));
126
+ const line = value === undefined ? undefined : `${bare(key)} = ${fmt(value)}`;
127
+ if (at >= 0) {
128
+ if (line === undefined)
129
+ lines.splice(at, 1);
130
+ else
131
+ lines[at] = line;
132
+ }
133
+ else if (line !== undefined)
134
+ lines.splice(block.start + 1, 0, line);
135
+ else
136
+ return;
137
+ writeText(file, withEol(lines, eol), what);
138
+ }
@@ -0,0 +1,4 @@
1
+ import { t } from "./i18n.js";
2
+ export const scopeLabel = (s) => t(`scope.${s}`);
3
+ export const SECTION_KINDS = ["mcp", "plugins", "skills", "hooks", "commands", "agents"];
4
+ export const sectionKindTitle = (k) => t(`section.${k}`);
@@ -0,0 +1,31 @@
1
+ import fs from "node:fs";
2
+ import { parseDocument } from "yaml";
3
+ import { writeText } from "./jsonio.js";
4
+ import { t } from "./i18n.js";
5
+ /*
6
+ * YAML editing through the yaml Document API: only the targeted nodes change,
7
+ * comments and the rest of the formatting are kept.
8
+ */
9
+ function load(file) {
10
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8").replace(/^/, "") : "";
11
+ const doc = parseDocument(text);
12
+ if (doc.errors.length)
13
+ throw new Error(t("err.badYaml", { file, msg: doc.errors[0].message }));
14
+ return doc;
15
+ }
16
+ export function readYaml(file) {
17
+ if (!fs.existsSync(file))
18
+ return {};
19
+ return load(file).toJS() ?? {};
20
+ }
21
+ /** Sets (or removes, value undefined) several paths, then writes the file once. */
22
+ export function editYaml(file, what, ops) {
23
+ const doc = load(file);
24
+ for (const op of ops) {
25
+ if (op.value === undefined)
26
+ doc.deleteIn(op.path);
27
+ else
28
+ doc.setIn(op.path, op.value);
29
+ }
30
+ writeText(file, String(doc), what);
31
+ }