diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 474f9ea..33b7844 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,6 +92,15 @@ Issues and pull requests are welcome. Before opening one: Screenshots go to `/tmp/t3-auto-update-ui-audit` (`T3_AUTO_UPDATE_UI_OUTPUT` overrides it). +For shared MCP changes, run `node scripts/mcp-native-smoke.mjs PROVIDER +/absolute/path/to/executable` for Claude, Codex, OpenCode (both V1 and V2) and +Cursor. It uses disposable homes and fixture servers without model requests. +`node scripts/mcp-ui-audit.js` checks the MCP page’s matrix, per-agent Off/On, +snippet import, masked values, edit/update, sharing consent, partial removal, +revision conflicts and failed/late reads in both themes at phone, desktop +and embedded sizes. It uses response fixtures, with screenshots under +`/tmp/t3-mcp-ui-audit`. The standard screenshot matrix includes `mcp`. + For OpenCode credential changes, run `node scripts/opencode-auth-smoke.mjs /absolute/path/to/opencode` against both 1.x and 2.x (including 2.0.18, the supported V2 floor). It uses an isolated diff --git a/README.md b/README.md index 8877da4..df827b2 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,8 @@ instead. Details: [Reaching the server from a phone](docs/remote-access.md). | [Source control](docs/source-control.md) | gh, glab, fj, tea and az sign-in | | [Publishing a port](docs/ports.md) | Public URLs for dev servers | | [Giving agents a browser](docs/agent-browser.md) | T3's server browser and the browser MCP servers | -| [Context management](docs/context-management.md) | Global `AGENTS.md` and skills | +| [Context management](docs/context-management.md) | Global `AGENTS.md` and the shared skill library | +| [MCP servers](docs/mcp-servers.md) | Define once, compare and configure native agent copies | | [Storage management](docs/storage.md) | Disk usage, cache cleanup, version retention | | [Configuration](docs/configuration.md) | Every environment variable, volumes, building | | [Troubleshooting](docs/troubleshooting.md) | Pairing, providers, terminals, permissions | diff --git a/docker/bin/t3-browser-mcp b/docker/bin/t3-browser-mcp index bd6cf85..172cdf5 100644 --- a/docker/bin/t3-browser-mcp +++ b/docker/bin/t3-browser-mcp @@ -50,8 +50,8 @@ Notes: * Chromium is sandboxed by the container already, so --no-sandbox is passed. * chrome-devtools-mcp officially supports Google Chrome rather than Debian's Chromium; it does work here, but playwright is the tested default. - * Each harness is registered through its managed executable. Install the - harness (setup page or `t3-harness install`) before registering MCP with it. + * Install the managed harness (setup page or `t3-harness install`) first. + Claude uses its native CLI; Codex/OpenCode use the setup config adapters. USAGE } @@ -105,8 +105,6 @@ harness_exec() { # id fi } -json_args="$(printf '%s\n' "${args[@]}" | jq -R . | jq -s .)" - register_claude() { local bin bin="$(harness_exec claude)" @@ -124,60 +122,33 @@ register_claude() { fi } +# Use the console's adapters so both entry points agree on JSONC precedence, +# OpenCode V2 nesting, TOML sections, backups and file symlinks. +write_browser_entry() { + "$T3_INFRA_NODE" --input-type=module - "$1" "$name" "$action" "$cmd" "${args[@]}" <<'NODE' +import * as io from "node:fs/promises"; +const { writeProviderMcpEntry } = await import(process.env.T3_MCP_ADAPTERS_MODULE || "file:///opt/t3-setup/mcp/adapters.mjs"); +const [provider, name, action, command, ...args] = process.argv.slice(2); +const entry = action === "remove" ? null : provider === "codex" + ? { command, args } : { type: "local", command: [command, ...args], enabled: true }; +await writeProviderMcpEntry(io, provider, { home: process.env.HOME, env: process.env }, name, entry); +NODE +} + register_codex() { local bin bin="$(harness_exec codex)" [ -n "$bin" ] || { echo " codex: not installed"; return; } - local config="${CODEX_HOME:-$HOME/.codex}/config.toml" - mkdir -p "$(dirname "$config")" - touch "$config" - - # Drop any block we previously wrote, then re-add if needed. - local tmp; tmp="$(mktemp)" - awk -v marker="# >>> t3-browser-mcp:${name} >>>" \ - -v endmarker="# <<< t3-browser-mcp:${name} <<<" ' - $0 == marker { skip = 1 } - skip != 1 { print } - $0 == endmarker { skip = 0 } - ' "$config" > "$tmp" - mv "$tmp" "$config" - - if [ "$action" = "remove" ]; then - echo " codex: removed $name" - return - fi - - { - printf '\n# >>> t3-browser-mcp:%s >>>\n' "$name" - printf '[mcp_servers.%s]\n' "$name" - printf 'command = "%s"\n' "$cmd" - printf 'args = %s\n' "$(printf '%s' "$json_args" | jq -c .)" - printf '# <<< t3-browser-mcp:%s <<<\n' "$name" - } >> "$config" - echo " codex: registered $name in $config (via $bin)" + write_browser_entry codex + echo " codex: $action $name (via $bin)" } register_opencode() { local bin bin="$(harness_exec opencode)" [ -n "$bin" ] || { echo " opencode: not installed"; return; } - local config="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json" - mkdir -p "$(dirname "$config")" - [ -s "$config" ] || echo '{"$schema":"https://opencode.ai/config.json"}' > "$config" - - local tmp; tmp="$(mktemp)" - if [ "$action" = "remove" ]; then - jq --arg n "$name" 'del(.mcp[$n])' "$config" > "$tmp" - mv "$tmp" "$config" - echo " opencode: removed $name" - return - fi - - jq --arg n "$name" --arg c "$cmd" --argjson a "$json_args" \ - '.mcp[$n] = {type: "local", command: ([$c] + $a), enabled: true}' \ - "$config" > "$tmp" - mv "$tmp" "$config" - echo " opencode: registered $name in $config (via $bin)" + write_browser_entry opencode + echo " opencode: $action $name (via $bin)" } printf '%s %s (%s)\n' \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7f79347..56ab57a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -268,9 +268,9 @@ register_projects # and az keep their sign-ins in these. AGENT_DIRS=".claude .codex .cursor .grok .config/opencode .local/share/opencode .config/muse .local/share/muse .agents/skills .local/state/skills .gemini/config/skills .gemini/antigravity-cli/skills" AGENT_DIRS="$AGENT_DIRS .config/gh .config/glab-cli .config/tea .local/share/forgejo-cli .azure" -# And git's own settings: who you commit as, and the credential helpers gh and -# tea set up so an HTTPS push uses their sign-in. git writes through the link. -AGENT_FILES=".gitconfig .agents/.skill-lock.json" +# And git's own settings, plus Claude's global account/MCP config outside its +# config directory. Git and Claude's native writer preserve these file links. +AGENT_FILES=".gitconfig .agents/.skill-lock.json .claude.json" persist_agent_credentials() { [ "$T3_PERSIST_AGENT_CREDENTIALS" = "1" ] || return 0 @@ -299,7 +299,10 @@ persist_agent_credentials() { # The skills CLI owns its JSON lock format. An absent lock stays absent; # its future write follows the link onto the volume without an empty or # invented JSON document. This does not move the rest of .agents. - if [ "$rel" != ".agents/.skill-lock.json" ]; then + if [ "$rel" = ".claude.json" ]; then + [ -e "$dst" ] || printf '{}\n' > "$dst" + chmod 600 "$dst" + elif [ "$rel" != ".agents/.skill-lock.json" ]; then [ -e "$dst" ] || : > "$dst" fi ;; diff --git a/docker/setup/app.js b/docker/setup/app.js index b1a7610..98cb97b 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -136,6 +136,7 @@ const storageUI = T3StorageUI.create({ api, getStatus: () => state.status, reload: () => loadStatus(), render: () => render() }); const skillsUI = T3SkillsUI.create({ api, getStatus: () => state.status, render: () => render(), readMarkdown: (file) => readMarkdownFile(file, 64 * 1024), downloadMarkdown: (...args) => downloadMarkdown(...args) }); + const mcpUI = T3McpUI.create({ api, getStatus: () => state.status, render: () => render() }); /** Whether this browser came in on its T3 Code session rather than the key, so Lock would end nothing. */ const viaT3 = (s) => Boolean(s && s.viewer && s.viewer.via === 't3'); @@ -212,6 +213,7 @@ tellAttention(); checkHere(); if (state.route === 'instructions' && state.contextTab === 'skills') skillsUI.activate(); + if (state.route === 'mcp') mcpUI.activate(); if (state.route === 'instructions' && state.contextTab === 'instructions') void loadInstructionsPreview(); } else if (!locked) { // Name the URL and the reason. "Could not read status" sent someone @@ -1784,6 +1786,7 @@ // ---------------------------------------------------------------- shell -- const PAGES = { overview: overviewPage, access: accessPage, agents: agentsPage, instructions: instructionsPage, + mcp: () => html`${mcpUI.phoneHead()}${pageHead('mcp', { sub: 'Servers your agents can start or connect to. Define one once, then add it to the agents you choose.' })}${mcpUI.content()}`, toolchains: toolchainsPage, sourcecontrol: sourceControlPage, ports: portsPage, updates: updatesPage, storage: storagePage, server: serverPage, more: morePage, }; @@ -1848,6 +1851,7 @@ // Access), which is rewritten in place so the address bar says where it is. const routeFromHash = () => { const [name, sub] = location.hash.replace(/^#\/?/, '').split('?')[0].split('/'); + if (name === 'instructions' && sub === 'mcp') { history.replaceState(null, '', '#mcp'); return 'mcp'; } const route = T3Routes.resolve(name); if (route && route !== name) history.replaceState(null, '', '#' + route + (sub ? '/' + sub : '')); if (route === 'instructions') state.contextTab = sub === 'skills' ? 'skills' : 'instructions'; @@ -1880,6 +1884,7 @@ if (route === 'ports' && !state.ports) loadPorts(); if (route === 'instructions' && state.contextTab === 'skills' && state.status) skillsUI.activate(true); if (route === 'instructions' && state.contextTab === 'instructions' && state.status) void loadInstructionsPreview(true); + if (route === 'mcp' && state.status) mcpUI.activate(true); }; const navigateFromHash = async focus => { const version = ++navigationVersion; @@ -1888,7 +1893,8 @@ // keeps the old route, tab and draft together. if (!navigationTask) navigationTask = (async () => { if (contextEditor.layer && !await contextEditor.requestClose()) return false; - return skillsUI.beforeNavigate(); + if (!await skillsUI.beforeNavigate()) return false; + return mcpUI.beforeNavigate(); })(); const allowed = await navigationTask; if (version !== navigationVersion) return; @@ -3509,6 +3515,18 @@ 'skills.group': (a) => skillsUI.group(a.id), 'skills.copy': (a) => skillsUI.copy(a.id, a.provider), 'skills.copy-group': (a) => skillsUI.copyGroup(a.id), + 'mcp.refresh': () => mcpUI.refresh(), + 'mcp.view': (a) => mcpUI.view(a.id, a.provider), + 'mcp.add': () => mcpUI.add(), + 'mcp.cell': (a, el) => mcpUI.cellMenu(a.id, a.provider, el), + 'mcp.agent': (a, el) => mcpUI.agentMenu(el), + 'mcp.filter': (a) => mcpUI.filterAgent(a.provider || ''), + 'mcp.attention': () => mcpUI.attention(), + 'mcp.columns': (a, el) => mcpUI.columnsMenu(el), + 'mcp.group': (a) => mcpUI.group(a.id), + 'mcp.share': (a) => mcpUI.share(a.id, a.provider), + 'mcp.link': (a) => mcpUI.toggle(a.provider, a.id), + 'mcp.apply': (a) => mcpUI.apply(a.provider, a.id), 'storage.goto': () => go('storage'), refresh: () => { loadStatus(); loadPorts(); }, goto: (a) => go(a.route), @@ -3868,6 +3886,7 @@ render(); }); document.addEventListener('change', (e) => { + if (e.target.id === 'mcp-provider') mcpUI.select(e.target.value); if (e.target.hasAttribute('data-skill-policy')) skillsUI.setState(e.target.getAttribute('data-skill-policy'), e.target.getAttribute('data-skill-provider'), e.target.value); }); @@ -3909,7 +3928,7 @@ // ------------------------------------------------------------ shortcuts -- Kit.bind('mod+k', openPalette); - Kit.bind('/', () => state.route === 'instructions' && state.contextTab === 'skills' ? skillsUI.focusSearch() : openPalette()); + Kit.bind('/', () => state.route === 'mcp' ? mcpUI.focusSearch() : state.route === 'instructions' && state.contextTab === 'skills' ? skillsUI.focusSearch() : openPalette()); for (const page of T3Routes.PAGES) { if (page.key) Kit.bind('g ' + page.key, () => { keyboardNav = true; go(page.route); }); } diff --git a/docker/setup/client/kit.js b/docker/setup/client/kit.js index ef8ebec..e767374 100644 --- a/docker/setup/client/kit.js +++ b/docker/setup/client/kit.js @@ -139,6 +139,13 @@ const Kit = (() => { }; PHONE.addEventListener('change', syncDensity); + // Native Tab focus can leave a partially visible filter clipped. Reveal the + // whole chip using its scrollport's focus-ring padding, without centering it. + document.addEventListener('focusin', (event) => { + const chip = event.target.closest?.('.tc-sk-agent-chips .tc-chip-btn'); + if (chip) chip.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); + }); + const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; const focusables = (root) => [...root.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement); const isTyping = (el) => Boolean(el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.nodeName)) && !(el.type === 'checkbox' || el.type === 'radio' || el.type === 'button')); @@ -331,7 +338,7 @@ const Kit = (() => { * items [{label, icon, danger, run, radio?, checked?, description?} | {sep: true}] * footer escaped Kit markup for settings paths or other contextual help */ - const menu = (trigger, { label, head, items, footer }) => { + const menu = (trigger, { label, head, items, footer, status }) => { const current = top(); if (current && current.kind === 'menu' && current.returnTo === trigger) { current.close('toggle'); return null; } if (isPhone()) { @@ -342,7 +349,7 @@ const Kit = (() => { returnTo: trigger, render: () => html`
${head && head.tile ? head.tile : ''}

${(head && head.title) || label}

${head && head.sub ? html`${head.sub}` : ''}
-
${menuItems(items)}${footer ? html`
${footer}
` : ''}
+
${status || ''}${menuItems(items)}${footer ? html`
${footer}
` : ''}
`, backdrop: 'close', }); @@ -359,7 +366,7 @@ const Kit = (() => { backdrop: false, returnTo: trigger, panel: { class: 'tc-menu', role: 'menu', 'aria-label': label }, - render: () => html`${head && head.title ? html`
${head.title}${head.sub ? ' · ' + head.sub : ''}
` : ''}${menuItems(items)}${footer ? html`
${footer}
` : ''}`, + render: () => html`${head && head.title ? html`
${head.title}${head.sub ? ' · ' + head.sub : ''}
` : ''}${status || ''}${menuItems(items)}${footer ? html`
${footer}
` : ''}`, onClose: () => trigger.setAttribute('aria-expanded', 'false'), }); // 4px under the trigger, right edges aligned; above it if there is no room. diff --git a/docker/setup/client/mcp-command.js b/docker/setup/client/mcp-command.js new file mode 100644 index 0000000..fdfd4ed --- /dev/null +++ b/docker/setup/client/mcp-command.js @@ -0,0 +1,32 @@ +// Shared by the browser editor and the pure snippet parser. This is a lexer, +// never a shell: variables, globs, substitutions and metacharacters stay literal. +var T3McpCommand = (() => { + 'use strict'; + const lex = text => { + const words = []; let word = '', quote = '', started = false; + for (let at = 0; at < text.length; at++) { + const c = text[at]; + if (quote === "'") { if (c === quote) quote = ''; else word += c; continue; } + if (c === '\\') { + if (++at >= text.length) throw new Error('Finish the escaped character at the end of the command.'); + const next = text[at]; + if (quote === '"' && !['$', '`', '"', '\\', '\n'].includes(next)) word += '\\'; + if (next !== '\n') word += next; + started = true; continue; + } + if (quote) { if (c === quote) quote = ''; else word += c; continue; } + if (c === '"' || c === "'") { quote = c; started = true; } + else if (/\s/.test(c)) { if (started) { words.push(word); word = ''; started = false; } } + else { word += c; started = true; } + } + if (quote) throw new Error('Close the quote in the command.'); + if (started) words.push(word); + return words; + }; + const quote = arg => /^[A-Za-z0-9_./:@=+-]+$/.test(arg) ? arg : "'" + arg.replace(/'/g, "'\\''") + "'"; + const line = argv => argv.map(quote).join(' '); + const needsRows = argv => argv.some(arg => !arg || arg !== arg.trim() || /[\r\n]/.test(arg)); + const placeholder = value => /<[^>]+>|YOUR_|your-/i.test(value) || /^(xxx|\.\.\.|changeme|replace-me|todo)$/i.test(value); + return { lex, quote, line, needsRows, placeholder }; +})(); +if (typeof module === 'object') module.exports = T3McpCommand; diff --git a/docker/setup/client/mcp-editor.js b/docker/setup/client/mcp-editor.js new file mode 100644 index 0000000..bbb2ab2 --- /dev/null +++ b/docker/setup/client/mcp-editor.js @@ -0,0 +1,311 @@ +// Add/edit/share are reviewed plans: save the definition, then serialize the +// selected native writes with the revisions captured when the form was read. +const T3McpEditor = (() => { + 'use strict'; + const { html, raw, icon } = Kit, S = T3McpModel, M = T3Model, C = T3McpCommand; + const disabled = value => value ? raw(' disabled') : ''; + const checked = value => value ? raw(' checked') : ''; + const equal = S.sameValue; + const create = ctx => { + let active = null, removeView = null, sequence = 0; + const open = async (options = {}) => { + if (!await close('replace') || removeView) return; + const mode = options.mode || (options.name ? 'edit' : 'add'), initial = ctx.library(); + const view = { mode, name: options.name || '', source: options.provider || '', replace: Boolean(options.replace), loading: mode !== 'add', busy: false, error: null, + snapshot: structuredClone(initial.data), agents: new Set(), consent: new Set(), versions: new Map(), server: null, original: null, baselineAgents: '', + snippet: '', parse: null, parsing: false, parseError: null, parseVersion: 0, parseTimer: null, edits: 0, parseWaiting: false, railOpen: !Kit.isPhone(), saved: null, results: null, + draft: { name: options.name || '', type: 'local', line: '', argv: [''], rows: false, url: '', local: [], remote: [] } }; + active = view; + const pairs = () => view.draft.type === 'local' ? view.draft.local : view.draft.remote; + const provider = id => view.snapshot.providers.find(p => p.provider === id); + const managed = initial.managed; + const newPair = (name = '', value = '', reveal = false) => ({ id: ++sequence, name, value, reveal }); + const loadServer = server => { + view.draft = { name: server.name, type: server.type, line: C.line(server.command || []), argv: server.command || [''], rows: C.needsRows(server.command || []), url: server.url || '', + local: Object.entries(server.env || {}).map(([key, value]) => newPair(key, value, mode === 'add')), + remote: Object.entries(server.headers || {}).map(([key, value]) => newPair(key, value, mode === 'add')) }; + }; + const serialize = () => { + const d = view.draft, entries = pairs().filter(row => row.name || row.value), names = entries.map(row => row.name); + const name = d.name.trim(); + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) || name.length > 64) throw new Error('Use 1–64 lowercase letters, numbers and single hyphens.'); + if (names.length > 64) throw new Error('A server holds at most 64 entries.'); + const remote = d.type === 'remote', normalized = names.map(name => remote ? name.toLowerCase() : name); + if (new Set(normalized).size !== names.length) throw new Error(remote ? 'Header names must be unique regardless of letter case.' : 'Variable names must be unique.'); + for (const pair of entries) { + if (!(remote ? /^[A-Za-z0-9-]+$/ : /^[A-Za-z_][A-Za-z0-9_]*$/).test(pair.name) || pair.name.length > 128) throw new Error('Enter a valid ' + (remote ? 'header' : 'variable') + ' name for each value.'); + if (pair.value.length > 8192 || (remote ? /[\r\n\0]/ : /\0/).test(pair.value)) throw new Error('Use a valid value of at most 8192 characters for ' + pair.name + '.'); + } + const values = Object.fromEntries(entries.map(row => [row.name, row.value])); + if (remote) { + let url; try { url = new URL(d.url.trim()); } catch { throw new Error('Enter a valid HTTP or HTTPS URL.'); } + if (!['https:', 'http:'].includes(url.protocol) || d.url.length > 2048 || /[\r\n\0]/.test(d.url)) throw new Error('Enter a valid HTTP or HTTPS URL of at most 2048 characters.'); + if (url.username || url.password) throw new Error('Put remote credentials in Headers, not in the URL.'); + if (url.pathname.replace(/\/$/, '').endsWith('/sse')) throw new Error('SSE-only servers aren’t portable. Use the server’s Streamable HTTP address.'); + return { name, type: 'remote', url: d.url.trim(), headers: values }; + } + const argv = d.rows ? d.argv : C.lex(d.line); + if (!argv.length || !argv[0] || argv.length > 32 || argv.some(arg => arg.length > 1024 || arg.includes('\0'))) throw new Error('Enter an executable and at most 31 arguments, each up to 1024 characters.'); + return { name, type: 'local', command: argv, env: values }; + }; + const validation = () => { try { serialize(); return null; } catch (error) { return error.message; } }; + const formState = () => JSON.stringify({ ...view.draft, local: view.draft.local.map(({ name, value }) => ({ name, value })), remote: view.draft.remote.map(({ name, value }) => ({ name, value })) }); + const choices = () => [...view.agents].sort().join(','); + const dirty = () => view.busy || Boolean(view.snippet) || view.original !== null && (view.source !== view.baselineSource || formState() !== view.original || choices() !== view.baselineAgents || view.consent.size > 0); + const closeDraft = async reason => { + if (view.busy || view.closing) { if (view.busy) Kit.announce('Wait for the server changes to finish saving.'); return false; } + if (dirty() && !view.saved) { + view.closing = true; + const yes = await Kit.confirm({ title: 'Discard changes?', body: 'Your MCP server changes have not been saved.', confirm: 'Discard changes', tone: 'danger' }); + view.closing = false; if (!yes || active !== view) return false; + } + view.layer.close(reason); return true; + }; + view.close = closeDraft; + const beforeUnload = event => { if (view.busy || !view.saved && dirty()) { event.preventDefault(); event.returnValue = ''; } }; + const collision = id => provider(id)?.own?.some(row => row.name === view.draft.name.trim()); + const existingShared = () => mode === 'add' && view.snapshot.shared.servers.some(s => s.name === view.draft.name.trim()); + const versionDiffers = id => { + const source = view.versions.get(view.source), other = view.versions.get(id); + return id !== view.source && S.has(provider(id), view.name) && (!other?.server || !S.sameDefinition(source?.server, other.server) || other.extra?.length > 0); + }; + const replacementNeeded = id => mode === 'share' ? versionDiffers(id) : mode === 'add' && collision(id); + const consentNeeded = () => [...view.agents].filter(id => replacementNeeded(id)); + const changedFields = () => { + if (!view.server || mode !== 'edit') return []; + let server; try { server = serialize(); } catch { return ['Form']; } + return [['type', 'Type'], ['command', 'Command'], ['url', 'URL'], ['env', 'Environment'], ['headers', 'Headers']].filter(([key]) => !equal(server[key], view.server[key])).map(([, label]) => label); + }; + const canSubmit = () => !view.loading && !view.busy && !view.saved && !view.error?.blocking && !view.error?.stale && (mode === 'share' ? Boolean(view.versions.get(view.source)?.server) + : !validation() && !existingShared() && (mode !== 'edit' || changedFields().length || choices() !== view.baselineAgents)) && consentNeeded().every(id => view.consent.has(id)); + const submitLabel = () => view.busy ? mode === 'share' ? 'Sharing' : mode === 'edit' ? 'Saving' : 'Adding' + : mode === 'share' ? 'Share and use in ' + M.plural(view.agents.size, 'agent') : mode === 'edit' ? view.agents.size ? 'Save and update ' + M.plural(view.agents.size, 'agent') : 'Save' : view.agents.size ? 'Add to ' + M.plural(view.agents.size, 'agent') : 'Add server'; + const sync = () => { + const panel = view.layer.panel; + for (const input of panel.querySelectorAll('[data-mcp-field]')) { + const key = input.dataset.mcpField; let value = view.draft[key]; + if (key === 'argv') value = view.draft.argv[Number(input.dataset.index)]; + if (key === 'pair-name' || key === 'pair-value') value = pairs().find(row => row.id === Number(input.dataset.pair))?.[key === 'pair-name' ? 'name' : 'value']; + if (input.value !== String(value ?? '')) input.value = value ?? ''; + } + for (const input of panel.querySelectorAll('[data-mcp-agent]')) input.checked = view.agents.has(input.dataset.mcpAgent); + for (const input of panel.querySelectorAll('[data-mcp-consent]')) input.checked = view.consent.has(input.dataset.mcpConsent); + for (const input of panel.querySelectorAll('[data-mcp-source]')) input.checked = input.dataset.mcpSource === view.source; + const submit = panel.querySelector('[data-mcp-editor="save"]'); if (submit) submit.disabled = !canSubmit(); + }; + const paint = () => { if (active !== view || view.layer?.closed) return; view.layer.render(); sync(); }; + view.repaint = paint; + const pickDefaults = () => { + if (mode !== 'add') return; + for (const p of managed) if (!provider(p.id)?.error) { if (collision(p.id)) view.agents.delete(p.id); } + }; + const fillSnippet = index => { + const server = view.parse?.servers?.[index]; if (!server) return; + loadServer(server); view.edits++; pickDefaults(); view.parseWaiting = false; paint(); + }; + const parse = async () => { + const version = ++view.parseVersion, edits = view.edits, snippet = view.snippet; + if (!snippet.trim()) { view.parse = null; view.parseError = null; view.parsing = false; paint(); return; } + view.parsing = true; view.parseError = null; view.parse = null; paint(); + const response = await ctx.api('/context/mcp/parse', { body: { text: snippet } }); + if (active !== view || view.busy || version !== view.parseVersion) return; + view.parsing = false; + if (!response.ok || !response.data.servers?.length) { view.parseError = response.error || response.data.errors?.join(' ') || 'No supported servers found in that snippet.'; paint(); return; } + view.parse = response.data; + if (view.edits !== edits) { view.parseWaiting = true; paint(); return; } + fillSnippet(0); + }; + const changedBadge = label => changedFields().includes(label) ? html`Changed` : ''; + const argv = () => { try { return view.draft.rows ? view.draft.argv : C.lex(view.draft.line); } catch { return []; } }; + const snippetForm = () => html`
${view.parse?.servers.length > 1 ? html`` : ''}

Also takes Codex TOML, OpenCode JSON, a claude mcp add command, a command line or a URL. Nothing you paste is run.

${view.parse?.notes?.map(note => html`

${note}

`)}${view.parse?.errors?.length ? html`

Some entries couldn’t be read: ${view.parse.errors.join(' ')}

` : ''}
`; + const pairValue = (pair, index) => { + const multiline = /[\r\n]/.test(pair.value), label = 'Value for ' + (pair.name || 'entry ' + (index + 1)); + const classes = 'tc-input tc-mcp-secret-input' + (C.placeholder(pair.value) ? ' tc-input--warn' : ''); + if (multiline && pair.reveal) return html``; + // Password inputs strip line breaks. Keep multiline values in memory + // until reveal rather than silently damaging them or relying on CSS masks. + return html``; + }; + const fields = () => html`
${mode === 'add' ? snippetForm() : ''}
${mode === 'add' && view.draft.name && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(view.draft.name) ? html`Use lowercase letters, numbers and single hyphens.` : ''}
Type ${changedBadge('Type')}
+ ${mode === 'edit' ? html`

Names can’t change. To rename, add it under the new name and remove this one.

` : ''} + ${existingShared() ? html`` : ''} + ${mode === 'add' ? managed.filter(p => collision(p.id)).map(p => html`
${p.name} already has its own ${view.draft.name}. Adding replaces it (backup kept).
`) : ''} + ${view.draft.type === 'local' ? html`
${view.draft.rows ? html`
${view.draft.argv.map((arg, index) => html`
${index ? html`` : html`Executable`}
`)}
` : html``}
${argv().map((arg, index) => html`${arg || '""'}`)}
${changedFields().includes('Command') ? html`

Was ${C.line(view.server.command || [])}

` : ''}
` : html`
${changedFields().includes('URL') ? html`

Was ${view.server.url} · Streamable HTTP

` : ''}
`} +
${changedBadge(view.draft.type === 'local' ? 'Environment' : 'Headers')}Values stay in this container. Lists show names only.
${pairs().map((pair, index) => html`
${pairValue(pair, index)}
`)}
${pairs().filter(pair => C.placeholder(pair.value)).map(pair => html`

${pair.name || 'This value'} still holds the snippet’s placeholder. Paste your token in its place.

`)}
+ ${validation() && (view.draft.name || view.draft.line || view.draft.url) ? html`

${validation()}

` : ''} + ${view.draft.type === 'remote' && mode === 'edit' ? html`
${icon('log-in')}
Sign-in isn’t stored hereIf ${view.name} asks you to sign in, each agent does it on its own. The commands are on the server’s page.
` : ''}${consents()}
`; + const consents = () => consentNeeded().map(id => html``); + const shareBody = () => { + const allVersions = [...view.versions], chosen = view.versions.get(view.source); + const identical = allVersions.length > 1 && allVersions.every(([, v]) => v.server && S.sameDefinition(v.server, chosen?.server)); + const versions = identical ? allVersions.filter(([id]) => id === view.source) : allVersions; + return html`

${versions.length > 1 ? 'Which version should every agent use?' : 'Share this server with your other agents'}

${versions.length > 1 ? M.listOf(versions.map(([id]) => S.nameOf(id))) + ' each have their own ' + view.name + ' entry.' : 'The source agent’s entry stays exactly as it is.'}

${versions.map(([id, version]) => html``)}

Values are copied as they are, secrets included. You can edit the shared server afterwards.${chosen?.extra?.length ? html` Options the shared format can’t hold are dropped: ${chosen.extra.join(', ')}. The source agent keeps them until its copy is updated.` : ''}

${consents()}
`; + }; + const railAgents = () => { + if (mode === 'add') return managed; + const rank = p => mode === 'share' ? p.id === view.source ? 0 : S.has(provider(p.id), view.name) ? 1 : 2 + : provider(p.id)?.linked?.includes(view.name) ? 0 : provider(p.id)?.diverged?.includes(view.name) ? 1 : 2; + return [...managed].sort((a, b) => rank(a) - rank(b)); + }; + const railHint = () => { + if (mode === 'edit') return 'Open sessions keep the old version until they end.'; + const differing = mode === 'share' ? managed.filter(p => versionDiffers(p.id)).map(p => p.name) : []; + if (differing.length) return 'Uncheck ' + M.listOf(differing) + ' to keep ' + (differing.length === 1 ? 'its own version. It then shows Differs.' : 'their own versions. They then show Differs.'); + return 'Nothing starts here. Agents start the server in their next session.'; + }; + const rail = () => html`
${mode === 'edit' ? 'When you save' : 'Use in'} · ${M.plural(view.agents.size, 'agent')}
`; + const resultBody = () => html`
${icon('info')}
Shared server ${mode === 'share' ? 'shared' : 'saved'}${M.plural(view.results.filter(r => r.ok).length, 'agent')} updated. Other entries stay as they are.
${view.results.map(result => html`
${ctx.mark(result.provider)}
${S.nameOf(result.provider)}${result.ok ? 'Saved' : result.error}
${icon(result.ok ? 'circle-check' : 'triangle-alert')}
`)}

Review the current settings before retrying a failed agent. Changes that succeeded are already saved.

`; + const title = () => mode === 'share' ? view.replace ? 'Use ' + S.nameOf(view.source) + '’s version of ' + view.name : 'Share ' + view.name : mode === 'edit' ? 'Edit ' + view.name : 'Add MCP server'; + view.layer = Kit.open({ kind: 'center', panel: { class: 'tc-wizard tc-mcp-editor', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'mcp-editor-title' }, backdrop: 'ignore', onEscape: () => { void closeDraft('escape'); }, + focus: '[data-mcp-editor="close"]', onClose: () => { clearTimeout(view.parseTimer); view.parseVersion++; window.removeEventListener('beforeunload', beforeUnload); view.versions.clear(); view.server = null; view.parse = null; view.draft.local = []; view.draft.remote = []; if (active === view) active = null; }, + render: () => html`

${title()}

${mode === 'share' ? view.replace ? 'Replace the shared definition, then update the agents you choose.' : 'Make it a shared server you manage for every agent from here.' : mode === 'edit' ? 'Shared server · used by ' + (M.listOf(S.used(view.snapshot, { name: view.name }, initial.installed).map(S.nameOf)) || 'no agents') : 'Define it once, then choose which agents use it.'}
${view.error ? html`` : ''}${view.loading ? html`

Reading server settings…

` : view.saved ? resultBody() : view.error?.blocking ? html`` : mode === 'share' ? shareBody() : fields()}
${!view.saved && !view.loading && !view.error?.blocking ? rail() : ''}
${mode === 'edit' && changedFields().length ? M.plural(changedFields().length, 'unsaved change') + ' · ' + changedFields().join(', ') : '⌘ ↵ to ' + (mode === 'share' ? 'share' : mode === 'edit' ? 'save' : 'add')}${!view.saved ? html`` : ''}
` }); + window.addEventListener('beforeunload', beforeUnload); + const submit = async () => { + if (!canSubmit()) return; + view.busy = true; view.error = null; clearTimeout(view.parseTimer); view.parseVersion++; paint(); + let response; + if (mode === 'share') response = await ctx.api('/context/mcp/adopt', { body: { provider: view.source, name: view.name, providerRevision: view.versions.get(view.source).providerRevision, + sharedRevision: view.snapshot.shared.revision, replace: view.replace } }); + else { + const server = serialize(); + response = await ctx.api('/context/mcp/' + (mode === 'add' ? 'create' : 'save'), { body: mode === 'add' ? { server } : { name: view.name, revision: view.snapshot.shared.revision, server } }); + } + if (!response.ok) { view.busy = false; view.error = { message: response.error, blocking: false, stale: response.status === 409 }; paint(); return; } + const name = view.draft.name.trim(), revision = response.data.revision; + view.saved = { name, revision }; view.results = []; + for (const id of view.agents) { + if (mode === 'share' && id === view.source) { view.results.push({ provider: id, ok: true }); continue; } + const result = await ctx.api('/context/mcp/link', { body: { provider: id, name, linked: true, sharedRevision: revision, + providerRevision: mode === 'share' && view.versions.has(id) ? view.versions.get(id).providerRevision : provider(id)?.revision, + ...(provider(id)?.disabled?.includes(name) ? { enabled: false } : {}) } }); + view.results.push({ provider: id, ok: result.ok, error: result.error }); + } + await ctx.refresh(); view.busy = false; + const failures = view.results.filter(r => !r.ok), success = view.results.length - failures.length; + Kit.toast(failures.length ? 'Added to ' + success + ' of ' + view.results.length + ' agents' : mode === 'share' ? 'Shared ' + name + ' with ' + M.plural(success, 'agent') : mode === 'edit' ? 'Saved ' + name + (success ? ' and updated ' + M.plural(success, 'agent') : '') : 'Added ' + name + (success ? ' to ' + M.plural(success, 'agent') : ''), { tone: failures.length ? 'danger' : 'ok', detail: failures.map(r => S.nameOf(r.provider) + ': ' + r.error).join(' · ') }); + if (!failures.length) view.layer.close('saved'); else paint(); + }; + view.layer.panel.addEventListener('input', event => { + if (view.busy) return; + if (event.target.id === 'mcp-snippet') { view.snippet = event.target.value; clearTimeout(view.parseTimer); view.parseVersion++; view.parseTimer = setTimeout(parse, 300); return; } + const key = event.target.dataset.mcpField; if (!key) return; + if (key === 'argv') view.draft.argv[Number(event.target.dataset.index)] = event.target.value; + else if (key === 'pair-name' || key === 'pair-value') pairs().find(p => p.id === Number(event.target.dataset.pair))[key === 'pair-name' ? 'name' : 'value'] = event.target.value; + else view.draft[key] = event.target.value; + if (key === 'name') { view.consent.clear(); pickDefaults(); } + view.edits++; paint(); + }); + view.layer.panel.addEventListener('paste', event => { + if (view.busy) return; + if (event.target.dataset.mcpSecret) { + const value = event.clipboardData.getData('text'); + if (view.draft.type === 'local' && /[\r\n]/.test(value)) { + event.preventDefault(); const pair = pairs().find(p => p.id === Number(event.target.dataset.mcpSecret)); pair.value = value; pair.reveal = true; view.edits++; paint(); + } + return; + } + if (event.target.dataset.mcpField !== 'pair-name') return; + const text = event.clipboardData.getData('text'); + const namePattern = view.draft.type === 'local' ? /^[A-Za-z_][A-Za-z0-9_]*$/ : /^[A-Za-z0-9-]+$/; + if (!text.split(/\r?\n/).filter(Boolean).every(line => line.includes('=') && namePattern.test(line.slice(0, line.indexOf('='))))) return; + const additions = text.split(/\r?\n/).filter(Boolean).map(line => { const at = line.indexOf('='); return newPair(line.slice(0, at), line.slice(at + 1), true); }); + if (pairs().length + additions.length - 1 > 64) return; + event.preventDefault(); const at = pairs().findIndex(p => p.id === Number(event.target.dataset.pair)); pairs().splice(at, 1, ...additions); view.edits++; paint(); + }); + view.layer.panel.addEventListener('change', event => { + if (view.busy) return; + if (event.target.id === 'mcp-snippet-choice') { fillSnippet(Number(event.target.value)); return; } + if (event.target.dataset.mcpAgent) { const id = event.target.dataset.mcpAgent; if (event.target.checked) view.agents.add(id); else view.agents.delete(id); view.consent.delete(id); } + if (event.target.dataset.mcpConsent) { const id = event.target.dataset.mcpConsent; if (event.target.checked) view.consent.add(id); else view.consent.delete(id); } + if (event.target.dataset.mcpSource) { view.source = event.target.dataset.mcpSource; if (managed.some(p => p.id === view.source)) view.agents.add(view.source); view.consent.clear(); } + paint(); + }); + view.layer.panel.addEventListener('toggle', event => { if (Kit.isPhone() && event.target.matches('.tc-wizard-rail-wrap')) view.railOpen = event.target.open; }, true); + view.layer.panel.addEventListener('keydown', event => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); void submit(); } }); + view.layer.panel.addEventListener('click', async event => { + const button = event.target.closest('[data-mcp-editor]'); if (!button || button.disabled) return; + const action = button.dataset.mcpEditor; + if (action === 'close') { await closeDraft('close'); return; } + if (view.busy) return; + if (action === 'save') { await submit(); return; } + if (action === 'reload') { if (await closeDraft('reload')) { await ctx.refresh(); await open(options); } return; } + if (action === 'review-result') { const name = view.saved.name; view.layer.close('review'); await ctx.edit(name); return; } + if (action === 'edit-existing') { const name = view.draft.name.trim(); if (await closeDraft('replace')) await ctx.edit(name); return; } + if (action === 'share-existing') { const name = view.draft.name.trim(); if (await closeDraft('replace')) await ctx.share(name, button.dataset.provider); return; } + if (action === 'clear-snippet') { clearTimeout(view.parseTimer); view.parseVersion++; view.snippet = ''; view.parse = null; view.parseError = null; view.parsing = false; const input = view.layer.panel.querySelector('#mcp-snippet'); if (input) input.value = ''; paint(); return; } + if (action === 'use-snippet') { fillSnippet(0); return; } + if (action === 'type') { view.draft.type = button.dataset.type; view.edits++; paint(); return; } + if (action === 'argv-mode') { + try { if (!view.draft.rows) view.draft.argv = C.lex(view.draft.line); else view.draft.line = C.line(view.draft.argv); view.draft.rows = !view.draft.rows; view.error = null; } + catch (error) { view.error = error.message; } + paint(); return; + } + if (action === 'add-arg') { view.draft.argv.push(''); view.edits++; paint(); view.layer.panel.querySelector('#mcp-arg-' + (view.draft.argv.length - 1))?.focus(); return; } + if (action === 'remove-arg') { view.draft.argv.splice(Number(button.dataset.index), 1); view.edits++; paint(); return; } + if (action === 'add-pair') { const pair = newPair(); pairs().push(pair); view.edits++; paint(); view.layer.panel.querySelector('[data-mcp-field="pair-name"][data-pair="' + pair.id + '"]')?.focus(); return; } + if (action === 'remove-pair') { const index = pairs().findIndex(p => p.id === Number(button.dataset.pair)); pairs().splice(index, 1); view.edits++; paint(); return; } + if (action === 'reveal') { const pair = pairs().find(p => p.id === Number(button.dataset.pair)); pair.reveal = !pair.reveal; paint(); } + }); + for (const p of managed) if (!provider(p.id)?.error && (mode !== 'edit' || provider(p.id)?.linked.includes(view.name))) view.agents.add(p.id); + if (mode === 'edit') { + const response = await ctx.api('/context/mcp/read?name=' + encodeURIComponent(view.name)); + if (active !== view) return; + if (!response.ok || response.data.revision !== view.snapshot.shared.revision) view.error = { message: response.error || 'Shared settings changed. Reload before editing.', blocking: true }; + else { view.server = response.data.server; loadServer(view.server); } + } else if (mode === 'share') { + const ids = view.snapshot.providers.filter(p => p.supported && S.has(p, view.name)).map(p => p.provider); + for (const id of ids) view.versions.set(id, { path: provider(id)?.path }); + await Promise.allSettled(ids.map(async id => { + const response = await ctx.api('/context/mcp/read?provider=' + id + '&name=' + encodeURIComponent(view.name)); + if (active !== view) return; + view.versions.set(id, response.ok && response.data.providerRevision === provider(id)?.revision ? response.data : { error: response.error || 'Settings changed. Reload to choose the current version.', path: provider(id)?.path }); + })); + if (active !== view) return; + view.source = ids.includes(options.provider) ? options.provider : ids.find(id => view.versions.get(id)?.server) || ''; + if (managed.some(p => p.id === view.source)) view.agents.add(view.source); + if (!view.versions.get(view.source)?.server) view.error = { message: view.versions.get(view.source)?.error || 'This entry’s format isn’t supported here.', blocking: true }; + view.draft.name = view.name; + } + if (active !== view) return; + view.loading = false; if (view.snapshot.shared.error) view.error = { message: view.snapshot.shared.error, blocking: true }; pickDefaults(); view.original = formState(); view.baselineAgents = choices(); view.baselineSource = view.source; paint(); + }; + const close = reason => active ? active.close(reason) : removeView ? false : true; + const remove = async (name, onSuccess) => { + if (removeView || !await close('remove')) return; + const view = { snapshot: structuredClone(ctx.library().data), busy: false, error: null, completed: [] }; + removeView = view; + const agents = () => view.snapshot.providers.filter(p => p.supported && S.has(p, name)); + const blocked = () => view.snapshot.providers.filter(p => p.supported && p.error); + const render = () => view.layer.render(); + const beforeUnload = event => { if (view.busy) { event.preventDefault(); event.returnValue = ''; } }; + window.addEventListener('beforeunload', beforeUnload); + view.layer = Kit.open({ kind: 'center', panel: { class: 'tc-dialog tc-mcp-remove', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'mcp-remove-title', 'aria-describedby': 'mcp-remove-desc' }, backdrop: 'ignore', focus: '[data-mcp-remove="cancel"]', onEscape: () => { if (!view.busy) view.layer.close('escape'); }, onClose: () => { window.removeEventListener('beforeunload', beforeUnload); if (removeView === view) removeView = null; }, + render: () => html`

Remove ${name}?

It’s taken out of every agent that has it, then the shared server is deleted.

${agents().filter(p => p.diverged.includes(name)).map(p => html`

${S.nameOf(p.provider)}’s copy differs from the shared server. It’s removed too.

`)}

A backup of each settings file is kept beside it. Open sessions keep ${name} until they end.

${blocked().length ? html`

${M.listOf(blocked().map(p => S.nameOf(p.provider)))} settings couldn’t be read. Resolve this before removing the shared server.

` : ''}${view.completed.length ? html`

Already removed from ${M.listOf(view.completed.map(S.nameOf))}.

` : ''}${view.error ? html`` : ''}
` }); + view.layer.panel.addEventListener('click', async event => { + const button = event.target.closest('[data-mcp-remove]'); if (!button || button.disabled || view.busy) return; + if (button.dataset.mcpRemove === 'cancel') { view.layer.close('cancel'); return; } + view.busy = true; view.error = null; render(); + for (const p of agents()) { + const result = await ctx.api('/context/mcp/link', { body: { provider: p.provider, name, linked: false, sharedRevision: view.snapshot.shared.revision, providerRevision: p.revision } }); + if (!result.ok) { view.error = S.nameOf(p.provider) + ': ' + result.error; break; } + view.completed.push(p.provider); + } + if (!view.error) { + const result = await ctx.api('/context/mcp/remove', { body: { name, revision: view.snapshot.shared.revision } }); + if (!result.ok) view.error = result.error; + } + await ctx.refresh(); view.busy = false; + if (!view.error) { view.layer.close('removed'); onSuccess(); Kit.toast('Removed ' + name); return; } + view.snapshot = structuredClone(ctx.library().data); render(); + }); + }; + return { open, close, remove, repaint: () => active?.repaint() }; + }; + const formatName = value => ({ 'claude-json': 'Claude Code or Cursor JSON', 'codex-toml': 'Codex TOML', 'opencode-json': 'OpenCode JSON', 'claude-mcp-add': 'claude mcp add', url: 'URL', command: 'command line', json: 'JSON' }[value] || value); + return { create }; +})(); diff --git a/docker/setup/client/mcp-inspector.js b/docker/setup/client/mcp-inspector.js new file mode 100644 index 0000000..393a7c6 --- /dev/null +++ b/docker/setup/client/mcp-inspector.js @@ -0,0 +1,100 @@ +// One read-only inspector; credentials stay in memory until explicitly revealed. +const T3McpInspector = (() => { + 'use strict'; + const { html, raw, icon } = Kit, S = T3McpModel, M = T3Model; + const disabled = value => value ? raw(' disabled') : ''; + const valueText = value => value === undefined ? 'Not set' : Array.isArray(value) ? T3McpCommand.line(value) : String(value); + const changed = (value, other) => { + const text = valueText(value), peer = valueText(other); + let first = 0, last = 0; + while (first < text.length && first < peer.length && text[first] === peer[first]) first++; + while (last < text.length - first && last < peer.length - first && text[text.length - last - 1] === peer[peer.length - last - 1]) last++; + return html`${text.slice(0, first)}${text.slice(first, text.length - last)}${last ? text.slice(-last) : ''}`; + }; + const create = ctx => { + let active = null; + const repaint = () => { if (active && !active.layer.closed) { active.layer.panel.classList.toggle('tc-sheet--bottom', Kit.isPhone()); active.layer.render(); + for (const select of active.layer.panel.querySelectorAll('[data-mcp-policy]')) { const id = select.dataset.mcpPolicy; if (!ctx.library().changing.has(id)) select.value = S.state(ctx.library().data, { name: active.name, shared: true }, id).kind; } + } }; + const close = reason => { if (!active) return true; active.layer.close(reason); return true; }; + const open = async (name, focusProvider) => { + close('replaced'); + const view = { name, server: null, revision: null, loading: true, error: null, differences: new Map(), revealed: new Set(), expanded: new Set(focusProvider ? [focusProvider] : []), generation: 0 }; + active = view; + const current = () => ctx.library(); + const native = id => current().data?.providers?.find(p => p.provider === id); + const sharedRow = () => ({ name, shared: true }); + const busy = id => current().changing.has(id); + const validDiff = id => { const d = view.differences.get(id); return d && d.sharedRevision === view.revision && d.sharedRevision === current().data?.shared?.revision && d.providerRevision === native(id)?.revision; }; + const request = async () => { + const generation = ++view.generation; view.loading = !view.server; view.refreshing = Boolean(view.server); view.error = null; repaint(); + const response = await ctx.api('/context/mcp/read?name=' + encodeURIComponent(name)); + if (active !== view || generation !== view.generation) return; + view.loading = false; view.refreshing = false; + if (!response.ok) { view.server = null; view.error = response.error; repaint(); return; } + view.server = response.data.server; view.revision = response.data.revision; view.differences.clear(); repaint(); + await Promise.allSettled(current().managed.filter(p => native(p.id)?.diverged?.includes(name)).map(async p => { + const diff = await ctx.api('/context/mcp/diff?provider=' + p.id + '&name=' + encodeURIComponent(name)); + if (active !== view || generation !== view.generation) return; + view.differences.set(p.id, diff.ok ? diff.data : { error: diff.error }); repaint(); + })); + }; + const diffBox = p => { + const diff = view.differences.get(p.id); + if (!diff) return html`

Reading differences…

`; + if (diff.error || !validDiff(p.id)) return html`
${diff.error || 'Settings changed while you were reviewing. Reload to see the current differences.'}
`; + return html`
${diff.fields.map(field => html`
${S.fieldLabel(field.field)}${field.secret ? html`
${field.key}

Values differ. Secret values aren’t shown side by side.

` : html`
Shared${changed(field.shared, field.agent)}${p.name}${changed(field.agent, field.shared)}
`}
`)}${diff.extraOptions?.length ? html`
Native optionsOnly in ${p.name}: ${diff.extraOptions.join(', ')}. Updating removes these options.
` : ''}
Backup kept
`; + }; + const agentRows = () => html`
Used by${S.used(current().data, sharedRow(), current().installed).length} of ${current().managed.length} agents
${current().managed.map(p => { + const state = S.state(current().data, sharedRow(), p.id), differs = state.kind === 'differs', added = ['on', 'off', 'differs'].includes(state.kind); + const expanded = !Kit.isPhone() || view.expanded.has(p.id), pending = current().changing.get(p.id) === name; + return html`
${ctx.mark(p.id)}
${p.name}${differs ? html` Differs` : ''}${state.reason || (differs ? 'Its copy doesn’t match the shared server' : added ? 'Matches the shared server' : 'Not added')}
+ ${pending ? html`` : state.kind === 'error' ? '' : differs ? html`${Kit.isPhone() ? html`` : ''}` : added && p.canDisable ? html`` : added ? html`` : html`${ctx.undos.get(p.id)?.name === name ? html`` : ''}`} + ${differs && expanded ? diffBox(p) : ''}
`; + })}${current().installed.includes('grok') && native('grok')?.imports?.length ? html`
${ctx.mark('grok')}
Grok Build${'May import ' + S.nameOf(S.state(current().data, sharedRow(), 'grok').source || 'claude') + '’s copy. Not managed here.'}
${S.state(current().data, sharedRow(), 'grok').label}
` : ''}

Each agent reads its own copy when a session starts.

`; + const connection = () => { + const server = view.server, remote = server.type === 'remote', pairs = Object.entries(remote ? server.headers || {} : server.env || {}); + return html`

Connection

Transport
${remote ? 'Streamable HTTP' : 'Command'}
${remote ? 'URL' : 'Command'}
${remote ? html`${server.url}` : html`${server.command.map((part, index) => html`${part || '""'}`)}`}
${pairs.map(([key, value], index) => html`
${remote ? 'Header' : 'Variable'}
${key}${view.revealed.has(key) ? value : '••••••••••••'}
`)}
`; + }; + const signIn = () => view.server.type !== 'remote' ? '' : html`
Sign-inNot shared between agents

Only needed when ${name} asks to sign in. Run it in a T3 Code terminal.

`; + view.layer = Kit.open({ kind: 'sheet', returnTo: document.activeElement, panel: { class: 'tc-sheet tc-skill-inspector tc-mcp-inspector' + (Kit.isPhone() ? ' tc-sheet--bottom' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'mcp-inspect-title' }, + focus: '[data-mcp-inspect="close"]', backdrop: 'ignore', onEscape: () => close('escape'), onClose: () => { view.revealed.clear(); view.differences.clear(); view.server = null; if (active === view) active = null; }, + render: () => html`

${name}

${view.refreshing ? html`` : ''}Shared server${view.server ? ' · ' + (view.server.type === 'remote' ? 'Remote' : 'Local') + ' · ' + M.plural(Object.keys(view.server.headers || view.server.env || {}).length, view.server.type === 'remote' ? 'header' : 'variable') : ''}
${view.error ? html`` : view.loading ? html`

Reading server…

` : view.server ? html`${[...ctx.mutationErrors.values()].filter(e => e.name === name).map(error => html``)}${agentRows()}${connection()}${signIn()}

Where it’s saved

${ctx.pathLabel(current().data?.shared?.path || '~/.t3/setup/mcp/servers.json')}

Backups: Beside each agent’s settings file when it changes. They hold secret values.

` : ''}
` }); + view.layer.panel.addEventListener('click', async event => { + const button = event.target.closest('[data-mcp-inspect]'); if (!button || button.disabled) return; + const action = button.dataset.mcpInspect, id = button.dataset.provider; + if (action === 'close') { close('close'); return; } + if (action === 'reload') { await ctx.refresh(); if (active === view) await request(); return; } + if (action === 'review') { if (view.expanded.has(id)) view.expanded.delete(id); else view.expanded.add(id); repaint(); return; } + if (action === 'reveal') { const key = Object.keys(view.server.headers || view.server.env || {})[Number(button.dataset.index)]; if (view.revealed.has(key)) view.revealed.delete(key); else view.revealed.add(key); repaint(); return; } + if (action === 'undo') { const undo = ctx.undos.get(id); if (undo?.name === name) await undo.run(); if (active === view) await request(); return; } + if (action === 'copy') { Kit.copy(view.server.type === 'remote' ? view.server.url : T3McpCommand.line(view.server.command), button); return; } + if (action === 'signin') { Kit.copy(signInCommand(id, name), button, 'Sign-in command copied'); return; } + if (action === 'edit') { await ctx.edit(name); return; } + if (action === 'delete') { await ctx.remove(name); return; } + if (action === 'adopt') { await ctx.share(name, id, true); return; } + if (action === 'menu') { Kit.menu(button, { label: name + ' actions', items: [ + { label: view.server?.type === 'remote' ? 'Copy URL' : 'Copy command', icon: 'copy', disabled: !view.server, run: () => Kit.copy(view.server.type === 'remote' ? view.server.url : T3McpCommand.line(view.server.command), button) }, + { sep: true }, { label: 'Remove…', icon: 'trash-2', danger: true, disabled: !view.server, run: () => ctx.remove(name) }, + ] }); return; } + const diff = view.differences.get(id); + await ctx.change(id, name, action !== 'remove', action === 'update' ? { reviewed: true, sharedRevision: diff.sharedRevision, providerRevision: diff.providerRevision } : {}); + if (active === view) await request(); + }); + view.layer.panel.addEventListener('change', async event => { + const id = event.target.dataset.mcpPolicy; if (!id) return; + const value = event.target.value; + await ctx.change(id, name, value !== 'remove', value === 'remove' ? {} : { enabled: value === 'on' }); + if (active === view) { await request(); const select = view.layer.panel.querySelector('[data-mcp-policy="' + id + '"]'); if (select) select.value = S.state(current().data, sharedRow(), id).kind; } + }); + await request(); + }; + return { open, close, repaint, restoreFocus: provider => { + if (!active || document.activeElement !== document.body && document.activeElement !== active.layer.panel) return; + const panel = active.layer.panel; + (panel.querySelector('[data-mcp-policy="' + CSS.escape(provider) + '"]') || panel.querySelector('[data-mcp-inspect="add"][data-provider="' + CSS.escape(provider) + '"]') || panel.querySelector('[data-mcp-inspect="close"]'))?.focus({ preventScroll: true }); + } }; + }; + const signInCommand = (id, name) => ({ claude: 'claude mcp login ', codex: 'codex mcp login ', opencode: 'opencode mcp auth ', cursor: 'cursor-agent mcp login ' }[id] || '') + name; + return { create, changed, signInCommand }; +})(); diff --git a/docker/setup/client/mcp-model.js b/docker/setup/client/mcp-model.js new file mode 100644 index 0000000..0118239 --- /dev/null +++ b/docker/setup/client/mcp-model.js @@ -0,0 +1,65 @@ +// Pure MCP view model. Shared definitions and native copies remain separate; +// On means configured for a new session, never a live connection claim. +const T3McpModel = (() => { + 'use strict'; + const providers = [ + { id: 'claude', name: 'Claude Code', control: 'Add or remove', managed: true }, + { id: 'codex', name: 'Codex', control: 'On or off', managed: true, canDisable: true }, + { id: 'opencode', name: 'OpenCode', control: 'On or off', managed: true, canDisable: true }, + { id: 'cursor', name: 'Cursor', control: 'Add or remove', managed: true }, + { id: 'grok', name: 'Grok Build', control: 'Imports only' }, + { id: 'muse', name: 'Muse Code', control: 'Not managed here' }, + { id: 'antigravity', name: 'Antigravity', control: 'Not managed here' }, + ]; + const columns = (installed, all = false) => providers.filter(provider => all || installed.includes(provider.id)); + const normalize = value => Array.isArray(value) ? value.map(normalize) : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value; + const sameValue = (a, b) => JSON.stringify(normalize(a)) === JSON.stringify(normalize(b)); + const definition = server => { + if (!server) return server; + const { enabled, ...rest } = server; + return rest; + }; + const sameDefinition = (a, b) => sameValue(definition(a), definition(b)); + const nameOf = id => providers.find(p => p.id === id)?.name || id; + const has = (provider, name) => ['linked', 'diverged'].some(key => provider?.[key]?.includes(name)) || provider?.own?.some(row => row.name === name); + const state = (data, row, id) => { + const provider = data?.providers?.find(p => p.provider === id); + if (id === 'grok') { + const source = (provider?.imports || []).find(id => has(data?.providers?.find(p => p.provider === id), row.name)); + return { kind: 'via', label: source ? 'Via ' + nameOf(source) : '—', source }; + } + if (!provider?.supported) return { kind: 'unsupported', label: '—', reason: provider?.error || 'Not managed here.' }; + if (provider.error) return { kind: 'error', label: '—', reason: provider.error }; + if (!row.shared && provider.own?.some(entry => entry.name === row.name)) return { kind: 'own', label: 'Own entry' }; + if (provider.diverged?.includes(row.name)) return { kind: 'differs', label: 'Differs' }; + if (provider.linked?.includes(row.name)) return provider.disabled?.includes(row.name) ? { kind: 'off', label: 'Off' } : { kind: 'on', label: 'On' }; + return { kind: 'none', label: '—' }; + }; + const rows = data => [ + ...(data?.shared?.servers || []).map(server => ({ ...server, shared: true, key: 'shared:' + server.name })), + ...(data?.ownGroups || []).map(group => ({ ...data.providers.flatMap(p => p.own || []).find(row => row.name === group.name), ...group, shared: false, key: 'own:' + group.name, + recognized: group.providers.some(id => data.providers.find(p => p.provider === id)?.own?.find(row => row.name === group.name)?.recognized) })), + ]; + const attention = (data, installed) => (data?.providers || []).filter(p => p.supported && (!installed || installed.includes(p.provider))).flatMap(p => [ + ...(p.error ? [{ kind: 'error', provider: p.provider, error: p.error }] : []), + ...(p.diverged || []).map(name => ({ kind: 'differs', provider: p.provider, name })), + ]); + const filter = (data, options = {}) => rows(data).filter(row => { + const search = [row.name, ...(row.command || []), row.url || '', ...(row.providers || []).flatMap(id => { + const own = data.providers.find(p => p.provider === id)?.own?.find(s => s.name === row.name); return [...(own?.command || []), own?.url || '']; + })].join(' ').toLowerCase(); + return (!options.query || search.includes(options.query.trim().toLowerCase())) + && (!options.agent || has(data.providers.find(p => p.provider === options.agent), row.name)) + && (!options.attention || attention(data, options.installed).some(item => item.name === row.name || item.kind === 'error')); + }); + const used = (data, row, installed) => providers.filter(p => p.managed && (!installed || installed.includes(p.id)) && has(data?.providers?.find(a => a.provider === p.id), row.name)).map(p => p.id); + const fieldLabel = field => ({ type: 'Type', command: 'Command', args: 'Arguments', url: 'URL', env: 'Variable', header: 'Header', format: 'Format' }[field] || field); + const describeDiff = diff => { + if (!diff) return 'Its copy no longer matches the shared server. Review the differences before replacing it.'; + const fields = (diff.fields || []).map(f => f.key ? f.key + (f.field === 'header' ? ' header' : ' variable') : f.field === 'url' ? 'URL' : fieldLabel(f.field).toLowerCase()); + if (diff.extraOptions?.length) fields.push('agent-specific options'); + return fields.length ? 'Its copy has a different ' + (fields.length < 2 ? fields.join('') : fields.slice(0, -1).join(', ') + ' and ' + fields.at(-1)) + ', so it no longer matches the shared server.' : 'Its copy no longer matches the shared server.'; + }; + return { providers, columns, nameOf, state, rows, attention, filter, used, has, fieldLabel, describeDiff, sameValue, sameDefinition }; +})(); diff --git a/docker/setup/client/mcp.js b/docker/setup/client/mcp.js new file mode 100644 index 0000000..c6421e3 --- /dev/null +++ b/docker/setup/client/mcp.js @@ -0,0 +1,178 @@ +// On-demand MCP library. Status polls cannot morph its owned subtree, focused +// cells, menus or drafts. Every mutation refreshes native truth before unlock. +const T3McpUI = (() => { + 'use strict'; + const { html, raw, icon } = Kit, S = T3McpModel, M = T3Model; + const disabled = value => value ? raw(' disabled') : ''; + const mark = id => html``; + const create = ({ api, getStatus, render }) => { + let data = null, query = '', agent = '', needsAttention = false, showAll = false, error = null, started = false, scanning = false, epoch = 0, scan = null, markup = null; + const mutationErrors = new Map(), undos = new Map(); + const changing = new Map(), collapsed = new Set(), differences = new Map(); + const installed = () => S.providers.filter(p => getStatus()?.harnesses?.some(h => h.id === p.id && h.installed)).map(p => p.id); + const managed = () => S.providers.filter(p => p.managed && installed().includes(p.id)); + const columns = () => S.columns(installed(), showAll); + const columnNote = provider => !provider.managed && provider.id !== 'grok' ? provider.control : installed().includes(provider.id) ? provider.control : 'Not installed'; + const rows = () => S.rows(data); + const library = () => ({ data, installed: installed(), managed: managed(), changing, home: getStatus()?.paths?.volume }); + const pathLabel = value => T3SkillModel.displayPath(value || '', library().home); + const detail = row => row.shared || row.identical ? row.type === 'remote' ? row.url : T3McpCommand.line(row.command || []) + : (row.providers?.length === 2 ? 'Two' : row.providers?.length) + ' different versions, in ' + M.listOf((row.providers || []).map(S.nameOf)); + const keyChips = row => html`${[...(row.envKeys || []), ...(row.headerKeys || [])].map(key => html`${icon('key-round')}${key}`)}`; + const paint = () => { + markup = body(); render(); + const mount = document.getElementById('mcp-library'); if (mount) Kit.patch(mount, markup); + inspector.repaint(); + }; + const load = (force = false) => { + if (scan && !force) return scan; + const version = ++epoch; started = true; scanning = true; error = null; paint(); + const request = api('/context/mcp' + (force ? '?refresh=1' : '')).then(response => { + if (version !== epoch) return false; + const usableInventory = response.status === 200 && Array.isArray(response.data?.shared?.servers) && Array.isArray(response.data?.providers); + if (!response.ok && !usableInventory) { error = response.error; return false; } + data = response.data; differences.clear(); + for (const [id, undo] of undos) if (undo.sharedRevision !== data.shared.revision || undo.providerRevision !== data.providers.find(p => p.provider === id)?.revision) undos.delete(id); + if (getStatus()?.context) getStatus().context.mcp = data; + // At most three detail reads; no credentials or values enter the list. + const attention = S.attention(data, installed()).filter(item => item.kind === 'differs').slice(0, 3); + for (const item of attention) void api('/context/mcp/diff?provider=' + item.provider + '&name=' + encodeURIComponent(item.name)).then(result => { + if (version !== epoch || !result.ok || result.data.sharedRevision !== data.shared.revision || result.data.providerRevision !== data.providers.find(p => p.provider === item.provider)?.revision) return; + differences.set(item.provider + ':' + item.name, { fields: result.data.fields.map(({ field, key }) => ({ field, key })), extraOptions: result.data.extraOptions }); + paint(); + }); + return true; + }).finally(() => { if (version === epoch) { scanning = false; scan = null; paint(); } }); + scan = request; return request; + }; + const change = async (provider, name, linked, options = {}) => { + const current = data?.providers?.find(p => p.provider === provider); + if (!current?.revision || changing.has(provider) || data.shared.error) return false; + const diverged = current.diverged?.includes(name), wasOff = current.disabled?.includes(name); + const sharedRevision = options.sharedRevision || data.shared.revision, providerRevision = options.providerRevision || current.revision; + if (diverged && !options.reviewed) { + const yes = await Kit.confirm({ title: linked ? 'Update ' + name + ' in ' + S.nameOf(provider) + '?' : 'Remove ' + name + ' from ' + S.nameOf(provider) + '?', + body: linked ? 'Replaces the complete native entry, including agent-specific options. A private backup is kept beside the settings file.' : 'This agent’s copy differs from the shared server. Its own changes are removed too. A private backup is kept beside the settings file.', + confirm: linked ? 'Update to shared' : 'Remove', tone: linked ? 'primary' : 'danger' }); + if (!yes || changing.has(provider)) return false; + } + const focusedKey = document.activeElement?.getAttribute('data-key'); + const fromInspector = Boolean(document.activeElement?.closest('.tc-mcp-inspector')); + const restoreFocus = () => { + if (document.activeElement !== document.body && !document.activeElement?.matches('.tc-mcp-inspector')) return; + if (focusedKey) document.querySelector('[data-key="' + CSS.escape(focusedKey) + '"]')?.focus({ preventScroll: true }); + if (fromInspector) inspector.restoreFocus(provider); + }; + mutationErrors.delete(provider); undos.delete(provider); changing.set(provider, name); paint(); + let response; + try { + response = await api('/context/mcp/link', { body: { provider, name, linked, sharedRevision, providerRevision, + ...(options.enabled !== undefined ? { enabled: options.enabled } : {}) } }); + await load(true); + } finally { changing.delete(provider); paint(); restoreFocus(); } + if (!response?.ok) { + const title = response?.status === 409 || response?.data?.code === 'conflict' ? 'Settings changed elsewhere. Showing the current state.' : 'Couldn’t change ' + name + ' in ' + S.nameOf(provider); + mutationErrors.set(provider, { name, title, error: response?.error }); paint(); + Kit.toast(title, { tone: 'danger', detail: response?.error }); + return false; + } + const text = linked ? options.enabled === false ? 'Turned off ' + name + ' in ' + S.nameOf(provider) : options.enabled === true ? 'Turned on ' + name + ' in ' + S.nameOf(provider) : 'Added ' + name + ' to ' + S.nameOf(provider) : 'Removed ' + name + ' from ' + S.nameOf(provider); + // Undo only a matched copy. It is revision checked against the post-write + // snapshot and preserves Off; it never replaces a newly changed copy. + const undoSharedRevision = data.shared.revision, undoProviderRevision = data.providers.find(p => p.provider === provider).revision; + const undo = !linked && !diverged ? { label: 'Undo', run: () => change(provider, name, true, { enabled: wasOff ? false : undefined, + sharedRevision: undoSharedRevision, providerRevision: undoProviderRevision }) } : null; + if (undo) undos.set(provider, { name, run: undo.run, sharedRevision: undoSharedRevision, providerRevision: undoProviderRevision }); + paint(); Kit.toast(text, { action: undo }); Kit.announce(text); return true; + }; + const context = { api, library, mutationErrors, undos, refresh: () => load(true), change, + edit: name => edit(name), share: (name, provider, replace = false) => share(name, provider, replace), remove: name => remove(name), mark, keyChips, pathLabel }; + const inspector = T3McpInspector.create(context), editor = T3McpEditor.create(context); + const prepare = async () => { if (!data && !await load()) return false; return true; }; + const edit = async name => { if (changing.size) return; if (!await prepare() || !await inspector.close('edit')) return; await editor.open({ name }); }; + const share = async (name, provider, replace = false) => { if (changing.size) return; if (!await prepare() || !await inspector.close('share')) return; await editor.open({ name, provider, mode: 'share', replace }); }; + const remove = async name => { if (!await prepare()) return; await editor.remove(name, () => inspector.close('removed')); }; + const cell = (row, provider) => { + const state = S.state(data, row, provider.id), busy = changing.has(provider.id), pending = changing.get(provider.id) === row.name; + const label = row.name + ' in ' + provider.name + ': ' + state.label.toLowerCase(); + const key = 'mcp-cell-' + row.key + ':' + provider.id; + if (provider.managed && !installed().includes(provider.id)) return html`${state.label}`; + if (pending) return html`Saving ${row.name} in ${provider.name}`; + if (['via', 'own', 'error', 'unsupported'].includes(state.kind)) return html`${state.label}`; + if (state.kind === 'none') return html``; + return html``; + }; + const groupLabel = shared => shared ? 'Shared servers' : 'Only in an agent’s settings'; + const footer = () => html`
${icon('info', 'tc-icon--sm')}Agents load servers when a session starts. Remote servers that need sign-in sign in from each agent.${data?.scannedAt ? 'Read ' + M.relTime(data.scannedAt, Date.now()) : ''}
`; + const table = filtered => html`
+
Server${installed().length < S.providers.length ? html`` : ''}
${columns().map(provider => html`
${mark(provider.id)}${provider.name}${data.providers.find(p => p.provider === provider.id)?.supported && data.providers.find(p => p.provider === provider.id)?.error ? html`` : ''}${columnNote(provider)}
`)}
+ ${[true, false].map(shared => { const group = filtered.filter(row => row.shared === shared); if (!group.length) return ''; return html`
${shared ? html`${pathLabel(data.shared.path || '~/.t3/setup/mcp/servers.json')}` : ''}${group.length}·${shared ? 'Defined once, added to each agent you choose' : 'Not shared. Share one to manage it for every agent from here.'}
+ ${collapsed.has(shared) ? '' : group.map(row => html`
${row.type ? html`${row.type === 'remote' ? 'Remote' : 'Local'}` : ''}${!shared ? html`` : ''}
${row.shared || row.identical ? icon(row.type === 'remote' ? 'globe' : 'terminal') : ''}${detail(row) || 'Unsupported native entry'}
${row.shared || row.identical ? keyChips(row) : ''}
${columns().map(provider => html`
${cell(row, provider)}
`)}
`)}
`; })} +
${footer()}
`; + const phone = filtered => html`
${[true, false].map(shared => { const group = filtered.filter(row => row.shared === shared); if (!group.length) return ''; return html`
${groupLabel(shared)}· ${group.length}
${group.map(row => { + const used = S.used(data, row, installed()), differs = used.filter(id => S.state(data, row, id).kind === 'differs'), off = used.filter(id => S.state(data, row, id).kind === 'off'); + return html``; + })}
`; })}
`; + const body = () => { + const all = rows(), filtered = S.filter(data, { query, agent, attention: needsAttention, installed: installed() }), attention = S.attention(data, installed()); + return html`
${attention.length ? html`` : ''}
+
${managed().map(p => html``)}
+ ${error ? html`` : ''} + ${data?.shared?.error ? html`` : ''} + ${attention.length ? html`` : ''} +
${attention.slice(0, 3).map(item => html`
${icon('triangle-alert')}
${item.kind === 'differs' ? item.name + ' differs in ' + S.nameOf(item.provider) : 'Couldn’t read ' + S.nameOf(item.provider) + '’s settings'}${item.error || S.describeDiff(differences.get(item.provider + ':' + item.name))}
${item.kind === 'differs' ? html`` : ''}
`)}${attention.length > 3 ? html`` : ''}
+ ${!data && scanning ? html`
Reading MCP servers…
` : data && all.length ? html`${!data.shared.servers.length ? html`

Your agents already have ${M.plural(all.length, 'server')}. Share them to manage them here.

` : ''}${filtered.length ? html`${table(filtered)}${phone(filtered)}` : html`
No servers matchTry another search or agent filter.
`}` : data && !data.shared.error ? html`
No MCP servers yetAdd one, or paste a snippet from a server’s README.
` : ''} + ${data && all.length && filtered.length ? html`` : footer()} +
`; + }; + const cellMenu = (name, provider, trigger) => { + const row = rows().find(row => row.name === name && row.shared), p = S.providers.find(p => p.id === provider), native = data?.providers?.find(p => p.provider === provider); + if (!row || !p || changing.has(provider)) return; + const state = S.state(data, row, provider), footer = html`Saved in ${pathLabel(native?.path)}. Applies to new sessions.`; + const remove = { label: 'Remove from ' + p.name, icon: 'trash-2', danger: true, run: () => change(provider, name, false) }; + let items = [remove]; + if (state.kind === 'differs') items = [ + { label: 'Update to the shared version', icon: 'refresh-cw', description: 'Replaces ' + p.name + '’s copy. A backup is kept.', run: () => change(provider, name, true) }, + { label: 'Show what differs', icon: 'columns-2', run: () => inspector.open(name, provider) }, + { label: 'Make this the shared version…', icon: 'share-2', run: () => share(name, provider, true) }, { sep: true }, remove, + ]; + else if (p.canDisable) items = [ + { label: 'On', radio: true, checked: state.kind === 'on', description: p.name + ' starts it in new sessions.', run: () => state.kind !== 'on' && change(provider, name, true, { enabled: true }) }, + { label: 'Off', radio: true, checked: state.kind === 'off', description: 'Kept in ' + pathLabel(native?.path) + ' but not started.', run: () => state.kind !== 'off' && change(provider, name, true, { enabled: false }) }, { sep: true }, remove, + ]; + Kit.menu(trigger, { label: p.name + ' · ' + name, head: { title: p.name + ' · ' + name }, + status: state.kind === 'differs' ? html`
${icon('triangle-alert')}${S.describeDiff(differences.get(provider + ':' + name))}
` : null, items, footer }); + }; + window.matchMedia('(max-width: 1023.98px)').addEventListener('change', () => { paint(); editor.repaint(); }); + document.addEventListener('input', event => { if (event.target.id === 'mcp-search') { query = event.target.value; paint(); } }); + return { + content: () => html`
${markup || body()}
`, + phoneHead: () => html`
${icon('chevron-right', 'tc-back-icon')}Setup

MCP servers

${document.documentElement.hasAttribute('data-embed') ? html`` : html`${icon('x')}`}
`, + activate: (force = false) => { + if (!started || force && !scanning && !changing.size) { void load(force); return; } + for (const label of document.querySelectorAll('#mcp-library .tc-sk-scan-time')) label.textContent = data?.scannedAt ? 'Read ' + M.relTime(data.scannedAt, Date.now()) : ''; + }, + refresh: async () => { if (changing.size) return; const ok = await load(true); if (ok) Kit.announce('MCP servers scanned'); }, + add: async () => { if (changing.size) return; if (await prepare() && await inspector.close('add')) await editor.open({}); }, view: async (name, provider) => { if (await prepare() && await editor.close('inspect')) await inspector.open(name, provider); }, share, + toggle: (provider, name) => change(provider, name, true), apply: (provider, name) => change(provider, name, true), cellMenu, + beforeNavigate: async () => await editor.close('route') && await inspector.close('route'), + focusSearch: () => document.getElementById('mcp-search')?.focus(), + filterAgent: id => { agent = id; if (!id) { query = ''; needsAttention = false; } paint(); const field = document.getElementById('mcp-search'); if (field) field.value = query; }, + attention: () => { needsAttention = !needsAttention; paint(); }, group: id => { const key = id === 'true'; if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key); paint(); }, + agentMenu: trigger => Kit.menu(trigger, { label: 'Used by', items: [{ label: 'Any agent', radio: true, checked: !agent, run: () => { agent = ''; paint(); } }, ...managed().map(p => ({ label: p.name, radio: true, checked: agent === p.id, run: () => { agent = p.id; paint(); } }))] }), + columnsMenu: trigger => { + const choose = value => { + if (showAll === value) return; + showAll = value; paint(); + const scroll = document.querySelector('.tc-mcp-table .tc-mx-scroll'); if (scroll) scroll.scrollLeft = 0; + Kit.announce('Showing ' + (showAll ? 'all agents' : 'installed agents') + ' in the MCP table.'); + }; + Kit.menu(trigger, { label: 'Agent columns', items: [ + { label: 'Installed agents', description: M.plural(installed().length, 'agent') + ' installed on this server.', radio: true, checked: !showAll, run: () => choose(false) }, + { label: 'All agents', description: 'Also show ' + M.listOf(S.providers.filter(provider => !installed().includes(provider.id)).map(provider => provider.name)) + '.', radio: true, checked: showAll, run: () => choose(true) }, + ], footer: 'Grok Build imports only. Muse Code and Antigravity aren’t managed here yet.' }); + }, + }; + }; + return { create }; +})(); diff --git a/docker/setup/client/model.js b/docker/setup/client/model.js index 1971f29..88195e2 100644 --- a/docker/setup/client/model.js +++ b/docker/setup/client/model.js @@ -1439,6 +1439,7 @@ const T3Model = (() => { access: warn(!s.publicUrl && s.server && connectView(s).state !== 'on' && connectView(s).state !== 'linking' ? 1 : 0), agents: warn(agents.filter((a) => a.attention).length) || running(busy(agents)), instructions: null, + mcp: warn((s.context?.mcp?.providers || []).filter(p => p.supported && s.harnesses?.some(a => a.id === p.provider && a.installed)).reduce((n, p) => n + (p.diverged?.length || 0) + (p.error ? 1 : 0), 0)), toolchains: warn(tools.filter((t) => t.state === 'failed').length) || running(busy(tools)), // A source control CLI someone installed and has not signed in is as // unusable to T3 Code as a failed one. The image's gh never counts. @@ -1583,6 +1584,10 @@ const T3Model = (() => { add(item.id === 'runtime' ? 'Updates' : 'Server', fixes[item.id], item.icon, item.action.cmd === 'goto' ? { cmd: 'goto', route: item.action.route } : { cmd: item.action.cmd, id: item.action.id }, { attention: true }); } + add('MCP servers', 'Add MCP server…', 'plug', { cmd: 'mcp.add' }); + add('MCP servers', 'Rescan MCP servers', 'refresh-cw', { cmd: 'mcp.refresh' }); + for (const server of s.context?.mcp?.shared?.servers || []) add('MCP servers', server.name, 'plug', { cmd: 'mcp.view', id: server.name }); + for (const provider of s.context?.mcp?.providers || []) for (const name of provider.diverged || []) add('MCP servers', 'Review ' + name + ' in ' + ({ claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', cursor: 'Cursor' }[provider.provider] || provider.provider), 'triangle-alert', { cmd: 'mcp.view', id: name, provider: provider.provider }, { attention: true }); add('Storage', 'Free up disk space', 'hard-drive', { cmd: 'storage.goto' }, { keywords: 'disk space caches cleanup folders usage manage storage', attention: Boolean(diskWarning(s)) }); add('Storage', 'Storage maintenance…', 'settings-2', { cmd: 'storage.preferences' }, { keywords: 'cache limits automatic cleanup retention preferences' }); @@ -1905,6 +1910,15 @@ const T3Model = (() => { }); }; + // Shared MCP servers: redacted summaries in /status, secrets only in the + // editor. Provider support is uneven; unsupported providers stay visible + // with their reason instead of failing the whole section. + const mcpView = (status) => { + const mcp = status?.context?.mcp || {}; + const shared = mcp.shared || {}; + return { present: Boolean(shared.present), count: shared.count || (shared.servers || []).length, revision: shared.revision || null, + bytes: shared.bytes || 0, error: shared.error || null, ok: mcp.ok !== false }; + }; // Automatic updates: the policy and last run from /status, as the Updates // page's switches say them. Pure, so the words are testable without a browser. const autoUpdateView = (status, now) => { @@ -1956,6 +1970,7 @@ const T3Model = (() => { readiness, readySummary, needsYou, containerItems, activity, setupBanner, setupFailures, publicUrlSource, hereOffer, connectView, contextView, contextAgentsMdRow, contextProviderRows, + mcpView, navBadges, serverWarnings, diskWarning, attentionCount, summaries, updatesView, runtimeView, autoUpdateView, paletteItems, searchPalette, redactedDiagnostics, storageView, diff --git a/docker/setup/client/routes.js b/docker/setup/client/routes.js index fa6e045..2d802d2 100644 --- a/docker/setup/client/routes.js +++ b/docker/setup/client/routes.js @@ -23,6 +23,8 @@ const T3Routes = (() => { summary: 'Install, sign in and update your coding agents.', doc: 'agents.md' }, { route: 'instructions', title: 'Instructions & skills', icon: 'book-open', key: 'i', group: 'agents', summary: 'What your agents read at the start of every session.', doc: 'context-management.md' }, + { route: 'mcp', title: 'MCP servers', icon: 'plug', key: 'm', group: 'agents', + summary: 'Servers your agents can start or connect to.', doc: 'mcp-servers.md' }, { route: 'toolchains', title: 'Toolchains', icon: 'wrench', key: 't', group: 'tools', summary: 'Languages and tools on every agent’s path, at exact versions.', doc: 'toolchains.md' }, { route: 'sourcecontrol', title: 'Source control', icon: 'git-pull-request', key: 's', group: 'tools', diff --git a/docker/setup/console.css b/docker/setup/console.css index 8f655b3..d0ea41d 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -1157,7 +1157,10 @@ html.tc-locked { overflow: hidden; } .tc-sk-search { flex: 1 1 100%; min-width: 0; } .tc-sk-search > .tc-input { min-height: 44px; font-size: 16px; } .tc-sk-search > .tc-kbd, .tc-sk-desktop-filters, .tc-sk-desktop-actions, .tc-sk-table { display: none !important; } - .tc-sk-agent-chips { display: flex; overflow-x: auto; overscroll-behavior-x: contain; gap: var(--space-2); margin-right: -16px; padding: 0 16px 4px 0; } + /* Keep the scrollport's 4px focus gutter within the library's bounds, + without moving its content or increasing the row's layout space. */ + .tc-skills-library { margin-inline: -4px; padding-inline: 4px; } + .tc-sk-agent-chips { display: flex; overflow-x: auto; overscroll-behavior-x: contain; gap: var(--space-2); margin: -4px; padding: 4px 4px 8px; scroll-padding-inline: 4px; } .tc-sk-agent-chips .tc-chip-btn { min-height: 44px; } .tc-sk-phone-list { display: flex; flex-direction: column; gap: var(--space-4); } .tc-prow { width: 100%; text-align: left; appearance: none; border: 0; background: none; cursor: pointer; font: inherit; } @@ -1302,3 +1305,260 @@ html.tc-locked { overflow: hidden; } .tc-sk-notices .tc-notice-desc, .tc-doc-preview .tc-notice-desc, .tc-context-sheet .tc-notice-desc { opacity: 1; } + +/* MCP servers: additions on top of the Skills library styles (console.css). + Tokens only, from the console design system. */ + +/* -------------------------------------------------------------- table --- */ +.tc-mcp-detail { display: flex; align-items: center; gap: 6px; min-width: 0; font: 400 12px/18px var(--font-mono); color: var(--fg-muted); } +.tc-mcp-detail > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.tc-mcp-detail > .tc-icon { width: 12px; height: 12px; flex: none; } +.tc-keys { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } +.tc-key { display: inline-flex; align-items: center; gap: 4px; height: 18px; padding: 0 5px; border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--fg) 5%, transparent); font: 400 11px/1 var(--font-mono); color: var(--fg-muted); white-space: nowrap; } +.tc-key > .tc-icon { width: 11px; height: 11px; } +.tc-mx-name .tc-badge { height: 18px; font-size: 11px; font-weight: 500; color: var(--fg-muted); background: color-mix(in srgb, var(--fg) 5%, transparent); } +.tc-mx-name .tc-btn--xs { margin-left: 2px; } + +.tc-cell[data-tone="warn"] { color: var(--warn-text); font-weight: 500; } +.tc-cell[data-tone="warn"] > .tc-dot { color: var(--warn); } +.tc-cell--via { color: var(--fg-muted); cursor: help; } +.tc-cell--via > span { text-decoration: underline dotted; text-decoration-color: var(--border-strong); text-underline-offset: 4px; } +.tc-cell--own { color: var(--fg); cursor: default; } +.tc-cell--own > span { color: var(--fg-muted); } + +.tc-menu-status { display: flex; gap: 8px; align-items: flex-start; margin: 2px 4px 6px; padding: 8px 10px; border-radius: var(--radius-md); + background: var(--warn-soft); color: var(--warn-text); font-size: 12px; line-height: 17px; } +.tc-menu-status > .tc-icon { width: 14px; height: 14px; flex: none; margin-top: 1px; } + +/* ---------------------------------------------------------- inspector --- */ +.tc-layer--sheet > .tc-mcp-inspector { width: min(560px, 100%); } +.tc-avail-row .tc-badge--warn { height: 18px; font-size: 11px; } +.tc-avail-row--open { background: color-mix(in srgb, var(--warn) 4%, transparent); } +.tc-diff { grid-column: 2 / -1; margin-top: 10px; border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius-lg); + background: var(--surface); overflow: hidden; } +[data-theme="dark"] .tc-diff { background: oklch(1 0 0 / 0.02); } +.tc-diff-row { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 4px 12px; padding: 9px 12px; font-size: 12px; line-height: 18px; } +.tc-diff-row + .tc-diff-row { border-top: 1px solid var(--hairline); } +.tc-diff-label { color: var(--fg-muted); } +.tc-diff-vals { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; min-width: 0; } +.tc-diff-who { color: var(--fg-muted); white-space: nowrap; } +.tc-diff-vals code { font-size: 12px; color: var(--fg); overflow-wrap: anywhere; } +.tc-diff-vals mark { background: var(--warn-soft); color: var(--warn-text); border-radius: 3px; padding: 0 1px; } +.tc-diff-foot { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 8px 10px 8px 12px; border-top: 1px solid var(--hairline); + background: color-mix(in srgb, var(--fg) 2.5%, transparent); } +.tc-diff-foot .tc-hint { margin-right: auto; } + +.tc-signin { list-style: none; margin: 0; padding: 0; } +.tc-signin li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 14px; font-size: 13px; } +.tc-signin li + li { border-top: 1px solid var(--hairline); } +.tc-signin .tc-tile { width: 20px; height: 20px; } +.tc-signin code { color: var(--fg); } + +.tc-secret-val { letter-spacing: 0.12em; color: var(--fg-muted); } + +/* ------------------------------------------------------------- editor --- */ +.tc-form { display: flex; flex-direction: column; gap: 18px; min-height: 0; } +.tc-form-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.tc-form-field > .tc-label { display: flex; align-items: center; gap: 8px; } +.tc-form-field > .tc-label .tc-spacer + * { font-weight: 400; } +.tc-form-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; } +.tc-form .tc-input { height: 32px; } +.tc-form .tc-input.tc-input--mono { font-family: var(--font-mono); font-size: 13px; } + +.tc-paste { position: relative; border-radius: var(--radius-lg); border: 1px dashed var(--border-strong); background: var(--surface-sunken); + padding: 10px 12px; font: 400 12px/18px var(--font-mono); color: var(--fg); white-space: pre; overflow: hidden; } +.tc-paste-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; line-height: 18px; color: var(--fg-muted); } +.tc-paste-meta > .tc-icon { width: 14px; height: 14px; color: var(--ok); } +.tc-paste-meta strong { color: var(--fg); font-weight: 500; } + +.tc-argv { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: var(--fg-muted); } +.tc-arg { display: inline-flex; align-items: center; height: 20px; padding: 0 6px; border-radius: var(--radius-xs); border: 1px solid var(--border); + background: var(--surface); font: 400 11.5px/1 var(--font-mono); color: var(--fg); } +[data-theme="dark"] .tc-arg { background: oklch(1 0 0 / 0.03); } +.tc-arg--exe { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); color: var(--accent-text); } +.tc-argv .tc-btn--link { font-size: 12px; color: var(--fg-muted); margin-left: auto; } + +.tc-kvs { border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; } +[data-theme="dark"] .tc-kvs { background: oklch(1 0 0 / 0.02); } +.tc-kv-row { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.4fr) 28px; gap: 6px; padding: 6px; align-items: center; } +.tc-kv-row + .tc-kv-row { border-top: 1px solid var(--hairline); } +.tc-kv-row .tc-input { height: 28px; font-family: var(--font-mono); font-size: 12.5px; padding: 0 9px; } +.tc-secret { position: relative; display: flex; min-width: 0; } +.tc-secret > .tc-input { padding-right: 32px; } +.tc-secret > .tc-btn { position: absolute; right: 2px; top: 2px; width: 24px; height: 24px; } +.tc-kv-foot { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-top: 1px solid var(--hairline); background: color-mix(in srgb, var(--fg) 2%, transparent); } +.tc-kv-foot .tc-hint { margin-left: auto; } + +.tc-changed { display: inline-flex; align-items: center; height: 18px; padding: 0 5px; border-radius: var(--radius-sm); background: var(--info-soft); + color: var(--info-text); font-size: 11px; font-weight: 500; } + +/* Rail rows: who gets the server, and what saving does to each agent. */ +.tc-agentpick { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-xl); background: var(--surface); overflow: hidden; } +[data-theme="dark"] .tc-agentpick { background: oklch(1 0 0 / 0.02); } +.tc-agentpick-row { display: grid; grid-template-columns: 18px auto minmax(0, 1fr); gap: 2px 10px; align-items: center; padding: 9px 12px; } +.tc-agentpick-row + .tc-agentpick-row { border-top: 1px solid var(--hairline); } +.tc-agentpick-row .tc-checkbox { margin: 0; } +.tc-agentpick-row .tc-row-main { gap: 0; } +.tc-agentpick-row .tc-avail-name { font-size: 13px; } +.tc-agentpick-row .tc-avail-note { font-size: 11.5px; line-height: 16px; } +.tc-agentpick-row[data-tone="warn"] .tc-avail-note { color: var(--warn-text); } +.tc-agentpick-row[data-off="true"] .tc-avail-name { color: var(--fg-muted); font-weight: 400; } + +/* Version choice when sharing an agent's own server. */ +.tc-choice { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 4px 12px; padding: 12px 14px; border: 1px solid var(--border); + border-radius: var(--radius-xl); background: var(--surface); cursor: pointer; } +[data-theme="dark"] .tc-choice { background: oklch(1 0 0 / 0.02); } +.tc-choice[data-checked="true"] { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); box-shadow: 0 0 0 3px var(--accent-soft); } +.tc-choice input[type="radio"] { appearance: none; -webkit-appearance: none; width: 16px; height: 16px; margin: 2px 0 0; border-radius: 50%; + border: 1px solid var(--border-strong); background: var(--bg); display: grid; place-items: center; } +.tc-choice input[type="radio"]:checked { border: 5px solid var(--accent); background: var(--on-accent); } +.tc-choice-head { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; } +.tc-choice-head .tc-tile { width: 20px; height: 20px; } +.tc-choice-body { grid-column: 2; display: flex; flex-direction: column; gap: 4px; min-width: 0; } + +/* Remove confirmation. */ +.tc-mcp-remove { width: min(460px, 100%); } +.tc-dialog-list { list-style: none; margin: 4px 0 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; } +.tc-dialog-list li { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 2px 10px; padding: 8px 12px; font-size: 13px; } +.tc-dialog-list li + li { border-top: 1px solid var(--hairline); } +.tc-dialog-list .tc-tile { width: 20px; height: 20px; } +.tc-dialog-list code { color: var(--fg-muted); font-size: 12px; } + +/* --------------------------------------------------------------- phone --- */ +.tc-prow-detail { grid-column: 1; margin: 0; font: 400 12px/18px var(--font-mono); color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tc-prow-name-line { display: flex; align-items: center; gap: 6px; min-width: 0; } +.tc-prow-name-line .tc-badge { height: 18px; font-size: 11px; color: var(--fg-muted); background: color-mix(in srgb, var(--fg) 5%, transparent); } +.tc-prow-agents .tc-warnline { color: var(--warn-text); } + +.tc-mx-name > a.tc-btn { height: 24px; font-size: 12px; font-weight: 500; line-height: 1; color: var(--fg); text-decoration: none; } +.tc-mx-name > a.tc-btn:hover { text-decoration: none; } +.tc-mcp-detail--prose { font-family: var(--font-sans); } +/* Inherited and own-entry cells are facts, not controls. */ +.tc-cell--via:hover, .tc-cell--own:hover, +[data-theme] .tc-cell--via:hover, [data-theme] .tc-cell--own:hover { background: none; border-color: transparent; box-shadow: none; } +.tc-input.tc-input--warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--border-strong)); } +.tc-input.tc-input--warn:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 18%, transparent); } + +/* MCP uses the same library/inspector/wizard shell as Skills. These are + composition rules only; the design system remains the shared source. */ +.tc-mcp-library { padding-bottom: 0; } +.tc-mcp-library .tc-sk-toolbar { margin-top: 0; } +.tc-mcp-library .tc-sk-desktop-filters { display: flex; align-items: center; gap: var(--space-2); } +.tc-mcp-table .tc-mx-head { position: relative; } +.tc-mcp-table .tc-mx-agent-name { min-width: 0; } +.tc-mcp-table .tc-mx-agent-name .tc-tile { width: 20px; height: 20px; } +.tc-mcp-table .tc-key { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } +.tc-mcp-loading, .tc-mcp-diff-loading { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-4); color: var(--fg-muted); font-size: 13px; } +.tc-cell--error, .tc-cell--unsupported { color: var(--fg-muted); cursor: help; } +.tc-cell--via { font-size: 12px; } +.tc-mcp-library .tc-warning, .tc-mcp-editor .tc-warning, .tc-mcp-inspector .tc-warning, .tc-mcp-remove .tc-warning { color: var(--warn-text); } +.tc-mcp-inspect-actions { display: flex; align-items: center; gap: var(--space-1); } +.tc-mcp-inspector .tc-pathlist { grid-template-columns: 82px minmax(0, 1fr); } +.tc-mcp-connection dd { flex-wrap: wrap; } +.tc-mcp-connection dd > .tc-btn { margin-left: auto; flex: none; } +.tc-mcp-secret-value { overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 12px; min-width: 0; } +.tc-mcp-path { overflow-wrap: anywhere; font-size: 12px; color: var(--fg-muted); } +.tc-mcp-inspector .tc-signin code { overflow-wrap: anywhere; min-width: 0; font-size: 12px; } +.tc-mcp-inspector .tc-insp-section h3 { margin: 0; font-weight: 400; } +.tc-mcp-editor .tc-form-field .tc-label { flex-wrap: wrap; } +.tc-mcp-editor .tc-form-row { grid-template-columns: minmax(0, 1fr) auto; } +.tc-mcp-editor .tc-form > .tc-hint { margin-top: -12px; } +.tc-mcp-editor .tc-form-field .tc-hint, .tc-mcp-editor .tc-wizard-rail .tc-hint { margin: 0; } +.tc-mcp-editor .tc-form-field > .tc-label > .tc-hint { font-weight: 400; font-size: 11.5px; } +.tc-mcp-editor .tc-paste { height: auto; min-height: 104px; resize: vertical; overflow: auto; white-space: pre-wrap; } +.tc-mcp-editor .tc-secret > .tc-input { padding-right: 32px; } +.tc-mcp-editor .tc-mcp-secret-input { resize: vertical; min-height: 28px; height: 28px; line-height: 18px; padding-block: 4px; overflow: auto; } +.tc-mcp-argument { display: flex; align-items: center; gap: var(--space-2); } +.tc-mcp-argument > .tc-input { resize: vertical; height: auto; min-height: 32px; font-size: 12px; } +.tc-mcp-argument .tc-hint { flex: none; font-size: 11px; } +.tc-mcp-arguments { display: flex; flex-direction: column; gap: var(--space-2); } +.tc-mcp-editor .tc-arg { max-width: 100%; height: auto; min-height: 20px; overflow-wrap: anywhere; white-space: pre-wrap; } +.tc-mcp-editor .tc-choice code { overflow-wrap: anywhere; min-width: 0; } +.tc-mcp-editor .tc-choice-head { flex-wrap: wrap; } +.tc-mcp-editor .tc-agentpick-row .tc-avail-note { overflow-wrap: anywhere; } +.tc-mcp-editor .tc-wizard-grid:has(> .tc-wizard-main:only-child) { grid-template-columns: minmax(0, 1fr); } +.tc-mcp-editor .tc-choice:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 3px; } +.tc-mcp-remove .tc-dialog-desc { margin-top: var(--space-3); } +.tc-mcp-remove .tc-dialog-list .tc-hint { display: block; margin-top: var(--space-1); } +.tc-mcp-remove .tc-dialog-foot .tc-btn { white-space: normal; } +@media (max-width: 1023.98px) { + .tc-page[data-route="mcp"] > .tc-page-head { display: none; } + .tc-page[data-route="mcp"] { padding-bottom: calc(88px + var(--tabbar-h)); } + .tc-mcp-library .tc-sk-desktop-filters { display: none; } + .tc-mcp-library .tc-sk-toolbar .tc-spacer { display: none; } + .tc-mcp-library .tc-sk-search { width: 100%; } + .tc-layer--sheet > .tc-mcp-inspector { width: 100%; max-height: 80dvh; } + .tc-mcp-inspector .tc-diff { grid-column: 1 / -1; } + .tc-mcp-inspector .tc-diff-row { grid-template-columns: minmax(0, 1fr); } + .tc-mcp-inspector .tc-diff-vals { grid-template-columns: minmax(0, 1fr); } + .tc-mcp-inspector .tc-diff-foot > .tc-btn { min-height: 44px; flex: 1; white-space: normal; } + .tc-mcp-inspector .tc-avail-row > .tc-mcp-inspect-actions { grid-column: 1 / -1; justify-content: flex-end; } + .tc-mcp-inspector .tc-avail-row > .tc-btn { grid-column: 2; justify-self: start; min-height: 44px; } + .tc-mcp-inspector .tc-mcp-inspector-remove { display: none; } + .tc-mcp-inspector .tc-insp-foot > .tc-btn { min-height: 44px; } + .tc-mcp-editor .tc-form-row { grid-template-columns: minmax(0, 1fr); } + .tc-mcp-editor .tc-input, .tc-mcp-editor .tc-kv-row .tc-input { min-height: 44px; height: auto; font-size: 16px; } + .tc-mcp-editor .tc-paste { min-height: 120px; font-size: 13px; } + .tc-mcp-editor .tc-mcp-secret-input { padding-block: 12px; } + .tc-mcp-editor .tc-secret > .tc-input { padding-right: 44px; } + .tc-mcp-editor .tc-secret > .tc-btn { min-width: 40px; min-height: 40px; } + .tc-mcp-editor .tc-kv-row { grid-template-columns: minmax(0, 1fr) 44px; gap: var(--space-2); } + .tc-mcp-editor .tc-kv-row > .tc-input { grid-column: 1; } + .tc-mcp-editor .tc-kv-row > .tc-secret { grid-column: 1; } + .tc-mcp-editor .tc-kv-row > .tc-btn { grid-column: 2; grid-row: 1 / 3; min-width: 44px; min-height: 44px; } + .tc-mcp-editor .tc-kv-foot .tc-btn, .tc-mcp-editor .tc-argv .tc-btn, .tc-mcp-editor .tc-seg button { min-height: 44px; } + .tc-mcp-editor .tc-argv .tc-btn { margin-left: 0; } + .tc-mcp-editor .tc-choice { padding: var(--space-4); } + .tc-mcp-editor .tc-choice-head { font-size: 14px; } + .tc-mcp-editor .tc-agentpick-row { padding: var(--space-3); } + .tc-mcp-editor .tc-wizard-foot .tc-btn--primary { white-space: normal; } + .tc-mcp-editor .tc-form-field > .tc-label > .tc-spacer { display: none; } + .tc-mcp-remove { max-height: 92dvh; overflow-y: auto; } +} +.tc-mcp-editor .tc-choice--summary { grid-template-columns: minmax(0, 1fr); cursor: default; } +.tc-mcp-editor .tc-choice--summary .tc-choice-body { grid-column: 1; } +.tc-mcp-editor .tc-wizard-head > .tc-tile { width: 36px; height: 36px; flex: none; border: 1px solid var(--border); border-radius: var(--radius-lg); } +@media (max-width: 1023.98px) { + #page-mcp { padding-top: 0; gap: 12px; --_gap: 12px; } +} +@media (max-width: 1023.98px) { + body[data-console-route="mcp"] .tc-phonebar, + body[data-console-route="mcp"] .tc-tabbar { display: none; } + .tc-mcp-editor .tc-wizard-head > .tc-spacer { display: none; } + .tc-mcp-editor .tc-wizard-head > .tc-sheet-head-text { flex: 1; } +} +.tc-mcp-editor .tc-mcp-secret-input { white-space: pre; scrollbar-width: none; } +.tc-mcp-editor textarea.tc-mcp-secret-input { height: auto; min-height: 56px; } +.tc-mcp-table .tc-mx[data-no-agents="true"] { --mx-cols: minmax(0, 1fr); min-width: 0; } +@media (max-width: 1023.98px) { + .tc-mcp-library .tc-sk-agent-chips { scrollbar-width: none; } +} +/* Modal feedback lives in its content, so earlier sticky page notifications + cannot cover an inspector policy or a dialog’s Save/Cancel controls. */ +.tc-root:has(.tc-mcp-inspector, .tc-mcp-editor, .tc-mcp-remove) .tc-toaster { visibility: hidden; } +#page-mcp .tc-page-sub { max-width: none; } +.tc-mcp-table > .tc-sk-library-foot { padding: 12px 16px; border-top: 1px solid var(--hairline); background: color-mix(in srgb, var(--fg) 2.5%, transparent); } +.tc-mcp-mobile-footer { display: none; } +@media (min-width: 1024px) { + #page-mcp { gap: 20px; --_gap: 20px; } + .tc-layer--sheet:has(> .tc-mcp-inspector) { padding: 0; } + .tc-layer--sheet > .tc-mcp-inspector { height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; } +} +@media (max-width: 1023.98px) { + .tc-mcp-mobile-footer { display: block; } +} +.tc-mcp-phone-menu { display: none; } +.tc-mcp-remove .tc-dialog-list li > span { display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px 4px; min-width: 0; } +.tc-mcp-remove .tc-dialog-list code { overflow-wrap: anywhere; } +@media (max-width: 1023.98px) { + .tc-mcp-inspector [data-mcp-menu-head] { display: none; } + .tc-mcp-inspector .tc-insp-foot > .tc-spacer { display: none; } + .tc-mcp-inspector .tc-insp-foot > .tc-mcp-phone-menu { display: inline-flex; min-width: 44px; } + .tc-mcp-inspector .tc-insp-foot > [data-mcp-inspect="edit"] { flex: 1; } +} +@media (min-width: 1024px) { .tc-mcp-editor .tc-paste { min-height: 112px; } } +@media (max-width: 1023.98px) { + .tc-mcp-library .tc-sk-notices { display: none; } + .tc-mcp-library .tc-mcp-phone-attention { display: flex; min-height: 60px; } +} diff --git a/docker/setup/design/ui.js b/docker/setup/design/ui.js index 15482ab..3a24be7 100644 --- a/docker/setup/design/ui.js +++ b/docker/setup/design/ui.js @@ -3,6 +3,7 @@ shipped console (docker/setup/app.js). Markup carries the state; this file only wires icons and the few interactions every surface shares. */ (function () { + // Lucide plug, used by the shared MCP list and editor. var ICONS = {"layout-dashboard": " ", "smartphone": " ", "bot": " ", "wrench": "", "git-pull-request": " ", "ethernet-port": " ", "settings-2": " ", "search": " ", "command": "", "copy": " ", "check": "", "x": " ", "external-link": " ", "qr-code": " ", "ellipsis": " ", "circle-arrow-up": " ", "download": " ", "trash-2": " ", "key-round": " ", "log-in": " ", "plus": " ", "refresh-cw": " ", "circle-alert": " ", "triangle-alert": " ", "info": " ", "circle-check": " ", "loader-circle": "", "sun": " ", "moon": "", "monitor": " ", "chevron-right": "", "chevron-down": "", "lock": " ", "link": " ", "globe": " ", "circle-stop": " ", "activity": "", "terminal": " ", "server": " ", "shield-check": " ", "eye": " ", "eye-off": " ", "laptop": "", "log-out": " ", "arrow-right": " ", "history": " "}; var SAMPLE = { qrPair: "\n", qrPort: "\n" }; @@ -41,6 +42,7 @@ ICONS.sparkles = ''; ICONS['file-text'] = ''; + ICONS.plug = ''; function agentMark(name, cls) { var mark = AGENT_MARKS[name]; if (!mark) return ''; diff --git a/docker/setup/mcp.mjs b/docker/setup/mcp.mjs new file mode 100644 index 0000000..f38b3ee --- /dev/null +++ b/docker/setup/mcp.mjs @@ -0,0 +1,165 @@ +// Shared definitions and native copies. Inventory never executes or connects +// to a server; only authenticated detail reads expose credential values. +import path from 'node:path'; +import { mcpStorePathFor, readMcpStore, createMcpServer, saveMcpServer, removeMcpServer, adoptMcpServer, + redactMcpServer, validateMcpServer, MCP_PROVIDERS, UNSUPPORTED_MCP_PROVIDERS, withStore } from './mcp/store.mjs'; +import { readProviderMcp, writeProviderMcpEntry, toClaudeEntry, toCodexEntry, toOpenCodeEntry, toCursorEntry, isSupportedMcpProvider } from './mcp/adapters.mjs'; +import { parseMcpSnippet } from './mcp/parse.mjs'; + +const problem = (code, message) => Object.assign(new Error(message), { code }); +const normalize = value => Array.isArray(value) ? value.map(normalize) : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value; +const equal = (a, b) => JSON.stringify(normalize(a)) === JSON.stringify(normalize(b)); +const content = ({ enabled, disabled, ...rest }) => rest; +const matches = (row, server) => Boolean(row?.canonical && !row.extra && equal(content(row.canonical), content(server))); +const assertProvider = provider => { + if (UNSUPPORTED_MCP_PROVIDERS[provider]) throw problem('unsupported', UNSUPPORTED_MCP_PROVIDERS[provider]); + if (!isSupportedMcpProvider(provider)) throw problem('invalid', 'Unknown MCP provider.'); +}; +export function mcpProviders() { + return [...Object.entries(MCP_PROVIDERS).map(([id, name]) => ({ id, name })), + ...Object.entries(UNSUPPORTED_MCP_PROVIDERS).map(([id, unsupported]) => ({ id, name: { grok: 'Grok Build', muse: 'Muse Code', antigravity: 'Antigravity' }[id], unsupported }))]; +} + +export function mcpDifferences(shared, row) { + const agent = row.canonical, fields = []; + if (!agent) return { fields: [{ field: 'format', kind: 'changed', shared: 'Shared definition', agent: 'Unsupported native entry' }], extraOptions: row.extraOptions || [], adoptable: false, dropped: row.extraOptions || [] }; + const add = (field, a, b, more = {}) => { + if (equal(a, b)) return; + const kind = a === undefined ? 'onlyAgent' : b === undefined ? 'onlyShared' : 'changed'; + fields.push({ field, kind, ...more, ...(!more.secret ? { ...(a !== undefined ? { shared: a } : {}), ...(b !== undefined ? { agent: b } : {}) } : {}) }); + }; + add('type', shared.type, agent.type); + add('command', shared.command?.[0], agent.command?.[0]); + add('args', shared.command?.slice(1), agent.command?.slice(1)); + add('url', shared.url, agent.url); + for (const [prop, field] of [['env', 'env'], ['headers', 'header']]) { + const a = shared[prop] || {}, b = agent[prop] || {}; + for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()) add(field, a[key], b[key], { key, secret: true }); + } + return { fields, extraOptions: row.extraOptions || [], adoptable: true, dropped: row.extraOptions || [] }; +} + +export function createMcpService({ home, env = process.env, io, baseDir, ttlMs = 15000 } = {}) { + if (!io) throw new Error('mcp service needs an fs implementation'); + home = path.resolve(home || env.HOME || '/home/t3'); + baseDir = baseDir || env.T3CODE_HOME || path.join(home, '.t3'); + let cached = null, at = 0; + const invalidate = () => { cached = null; at = 0; }; + const native = provider => readProviderMcp(io, provider, { home, env }); + const storeRead = async () => { + const store = await readMcpStore(io, baseDir); + if (store.error) throw problem(store.code || 'unavailable', store.error); + return store; + }; + const getServer = (store, name) => { + const server = store.servers.find(s => s.name === name); + if (!server) throw problem('missing', 'This shared MCP server no longer exists. Refresh the list.'); + return server; + }; + const stateFor = (provider, read, shared) => { + const linked = [], diverged = [], unlinked = [], disabled = []; + for (const server of shared) { + const row = read.servers.find(r => r.name === server.name); + (row ? matches(row, server) ? linked : diverged : unlinked).push(server.name); + if (row?.canonical?.enabled === false) disabled.push(server.name); + } + const own = read.servers.filter(r => !shared.some(s => s.name === r.name)).map(row => row.canonical + ? { ...redactMcpServer(row.canonical), recognized: true, extra: row.extraOptions || [] } + : { name: row.name, recognized: false, extra: row.extraOptions || [] }); + return { provider, supported: true, path: read.path, present: read.present, revision: read.revision, linked, diverged, unlinked, disabled, own, ownCount: own.length, + error: null, warning: read.servers.some(row => !row.canonical) ? 'One or more entries use an unsupported shape and are left in place.' : null }; + }; + const status = async ({ refresh = false } = {}) => { + if (!refresh && cached && Date.now() - at < ttlMs) return cached; + const promise = (async () => { + const store = await readMcpStore(io, baseDir), reads = new Map(); + const providers = await Promise.all(['claude', 'codex', 'opencode', 'cursor', 'grok', 'muse', 'antigravity'].map(async provider => { + if (UNSUPPORTED_MCP_PROVIDERS[provider]) return { provider, supported: false, error: UNSUPPORTED_MCP_PROVIDERS[provider], ...(provider === 'grok' ? { imports: ['claude', 'cursor'] } : {}), linked: [], diverged: [], unlinked: [], disabled: [], own: [], ownCount: 0 }; + try { const read = await native(provider); reads.set(provider, read); return stateFor(provider, read, store.servers); } + catch (error) { return { provider, supported: true, revision: null, linked: [], diverged: [], unlinked: [], disabled: [], own: [], ownCount: 0, + error: String(error?.message || error).slice(0, 240), code: error?.code }; } + })); + const groups = new Map(); + for (const provider of providers) for (const own of provider.own) { + if (!groups.has(own.name)) groups.set(own.name, []); + groups.get(own.name).push(provider.provider); + } + const ownGroups = [...groups].map(([name, ids]) => { + const rows = ids.map(id => reads.get(id).servers.find(r => r.name === name)); + return { name, providers: ids, identical: rows.every(row => row.canonical && equal(content(row.canonical), content(rows[0].canonical))) }; + }); + return { ok: !store.error, shared: { path: store.path, present: store.present, count: store.servers.length, revision: store.revision, bytes: store.bytes, + error: store.error ?? null, servers: store.servers.map(redactMcpServer) }, providers, ownGroups, scannedAt: Date.now() }; + })(); + cached = promise; at = Date.now(); + promise.catch(() => { if (cached === promise) invalidate(); }); + return promise; + }; + const read = async (name, provider) => { + const store = await storeRead(); + if (!provider) return { ok: true, server: getServer(store, name), revision: store.revision }; + assertProvider(provider); + const detail = await native(provider), row = detail.servers.find(r => r.name === name); + if (!row) throw problem('missing', 'This agent no longer has that server. Rescan to see the current state.'); + return { ok: true, server: row.canonical, recognized: Boolean(row.canonical), extra: row.extraOptions, path: detail.path, + revision: store.revision, providerRevision: detail.revision }; + }; + const diff = async (provider, name) => { + assertProvider(provider); + const store = await storeRead(), server = getServer(store, name), detail = await native(provider), row = detail.servers.find(r => r.name === name); + if (!row) throw problem('missing', 'This agent no longer has that server. Rescan to see the current state.'); + return { ok: true, provider, name, sharedRevision: store.revision, providerRevision: detail.revision, ...mcpDifferences(server, row) }; + }; + const setLink = async (provider, name, linked, sharedRevision, providerRevision, enabled) => withStore(mcpStorePathFor(baseDir), async () => { + assertProvider(provider); + if (typeof linked !== 'boolean' || (enabled !== undefined && typeof enabled !== 'boolean')) throw problem('invalid', 'Expected boolean linked and enabled values.'); + if (enabled === false && !['codex', 'opencode'].includes(provider)) throw problem('unsupported', `${MCP_PROVIDERS[provider]} has no global off switch. Remove it instead.`); + const store = await storeRead(); + if (store.revision !== sharedRevision) throw problem('conflict', 'Shared MCP servers changed. Refresh the list before changing links.'); + const server = getServer(store, name); + const current = await native(provider), existing = current.servers.find(r => r.name === name); + const on = enabled ?? existing?.canonical?.enabled ?? server.enabled !== false; + const entry = !linked ? null : provider === 'codex' ? toCodexEntry(server, on) : provider === 'opencode' ? toOpenCodeEntry(server, false, on) + : provider === 'claude' ? toClaudeEntry(server) : toCursorEntry(server); + const result = await writeProviderMcpEntry(io, provider, { home, env }, name, entry, { revision: providerRevision }); + invalidate(); return { ...result, ok: true, provider, name, linked }; + }); + const adopt = async (provider, name, providerRevision, sharedRevision, replace = false) => { + assertProvider(provider); + if (typeof replace !== 'boolean') throw problem('invalid', 'Expected a boolean replacement choice.'); + const result = await adoptMcpServer(io, baseDir, name, sharedRevision, async () => { + const detail = await native(provider); + if (detail.revision !== providerRevision) throw problem('conflict', 'Agent settings changed. Review the current entry before sharing it.'); + const row = detail.servers.find(r => r.name === name); + if (!row?.canonical) throw problem('unsupported', 'This entry’s format isn’t supported here.'); + return { server: row.canonical, dropped: row.extraOptions, verify: async () => { + if ((await native(provider)).revision !== providerRevision) throw problem('conflict', 'Agent settings changed. Review the current entry before sharing it.'); + } }; + }, replace); + invalidate(); return result; + }; + const mutation = operation => async (...args) => { + const result = await operation(io, baseDir, ...args); + if (!result.ok) throw problem(result.code || 'invalid', result.error); + invalidate(); return { ok: true, ...result }; + }; + return { status, read, diff, setLink, adopt, parse: parseMcpSnippet, providers: mcpProviders, validate: validateMcpServer, + list: async provider => { + assertProvider(provider); + const store = await storeRead(), detail = await native(provider), state = stateFor(provider, detail, store.servers); + return { ok: true, ...state, sharedRevision: store.revision, shared: store.servers.map(s => ({ ...redactMcpServer(s), + linked: state.linked.includes(s.name), diverged: state.diverged.includes(s.name), linkable: true })), unsupported: detail.servers.filter(r => !r.canonical).map(r => r.name) }; + }, + create: mutation(createMcpServer), save: mutation(saveMcpServer), + remove: async (name, revision) => { + const result = await removeMcpServer(io, baseDir, name, revision, async () => { + for (const provider of Object.keys(MCP_PROVIDERS)) if ((await native(provider)).servers.some(r => r.name === name)) + throw problem('conflict', 'Unlink this server from every provider before removing its shared definition.'); + }); + if (!result.ok) throw problem(result.code || 'invalid', result.error); + invalidate(); return { ok: true, ...result }; + }, + }; +} +export { mcpStorePathFor }; diff --git a/docker/setup/mcp/adapters.mjs b/docker/setup/mcp/adapters.mjs new file mode 100644 index 0000000..343aff0 --- /dev/null +++ b/docker/setup/mcp/adapters.mjs @@ -0,0 +1,158 @@ +// Provider-native config translation. Inventory is read-only; explicit apply +// and unlink use revision-checked, symlink-preserving native-settings writes. +import path from "node:path"; +import { parse, parseTree, modify, applyEdits } from "../vendor/jsonc.mjs"; +import { parseMcpToml, applyMcpToml } from "./toml.mjs"; +import { readConfig, changeConfig } from "./files.mjs"; +import { validateMcpServer } from "./store.mjs"; + +const problem = (code, message) => Object.assign(new Error(message), { code }); +const object = (value) => value && typeof value === "object" && !Array.isArray(value); +const homeOf = (home, env) => path.resolve(home || env.HOME || "/home/t3"); +export const claudeMcpPath = (home, env) => env.CLAUDE_CONFIG_DIR?.trim() + ? path.join(path.resolve(homeOf(home, env), env.CLAUDE_CONFIG_DIR), ".claude.json") : path.join(homeOf(home, env), ".claude.json"); +export const codexMcpPath = (home, env) => path.join(path.resolve(homeOf(home, env), env.CODEX_HOME || ".codex"), "config.toml"); +export const cursorMcpPath = (home, env) => path.join(homeOf(home, env), ".cursor", "mcp.json"); +export const openCodeConfigPath = async (io, home, env) => { + const root = path.resolve(homeOf(home, env), env.OPENCODE_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || ".config", "opencode")); + const jsonc = path.join(root, "opencode.jsonc"); + try { await io.lstat(jsonc); return { file: jsonc, jsonc: true }; } + catch (e) { if (e.code !== "ENOENT") throw e; return { file: path.join(root, "opencode.json"), jsonc: false }; } +}; + +export function toClaudeEntry(s) { + return s.type === "local" ? { command: s.command[0], args: s.command.slice(1), ...(Object.keys(s.env).length ? { env: { ...s.env } } : {}) } + : { type: "http", url: s.url, ...(Object.keys(s.headers).length ? { headers: { ...s.headers } } : {}) }; +} +export function fromClaudeEntry(name, entry) { + if (!object(entry)) return null; + if (entry.type && !["http", "streamable-http", "stdio"].includes(entry.type)) return null; + if (["http", "streamable-http"].includes(entry.type) && typeof entry.url === "string" && /^https?:\/\//.test(entry.url)) return { name, type: "remote", url: entry.url, headers: { ...(entry.headers ?? {}) }, enabled: true }; + if (typeof entry.command === "string" && entry.command) return { name, type: "local", command: [entry.command, ...(Array.isArray(entry.args) ? entry.args : [])], env: { ...(entry.env ?? {}) }, enabled: true }; + return null; +} +export function toCodexEntry(s, enabled = s.enabled !== false) { + const entry = s.type === "local" ? { command: s.command[0], args: s.command.slice(1), ...(Object.keys(s.env).length ? { env: { ...s.env } } : {}) } + : { url: s.url, ...(Object.keys(s.headers).length ? { http_headers: { ...s.headers } } : {}) }; + if (!enabled) entry.enabled = false; + return entry; +} +export function fromCodexEntry(name, entry) { + if (!object(entry)) return null; + if (typeof entry.url === "string" && entry.url) return { name, type: "remote", url: entry.url, headers: { ...(entry.http_headers ?? {}) }, enabled: entry.enabled !== false }; + if (typeof entry.command === "string" && entry.command) return { name, type: "local", command: [entry.command, ...(Array.isArray(entry.args) ? entry.args : [])], env: { ...(entry.env ?? {}) }, enabled: entry.enabled !== false }; + return null; +} +export function toOpenCodeEntry(s, v2 = false, enabled = s.enabled !== false) { + const state = v2 ? { disabled: !enabled } : { enabled }; + return s.type === "local" ? { type: "local", command: [...s.command], ...(Object.keys(s.env).length ? { environment: { ...s.env } } : {}), ...state } + : { type: "remote", url: s.url, ...(Object.keys(s.headers).length ? { headers: { ...s.headers } } : {}), ...state }; +} +export function fromOpenCodeEntry(name, entry, v2 = false) { + if (!object(entry)) return null; + // The legacy loader can discard the other layout's state flag. It must + // not make inventory claim that a shared disabled definition was applied. + const enabled = v2 ? entry.disabled !== true : entry.enabled !== false; + if (entry.type === "remote" && typeof entry.url === "string") return { name, type: "remote", url: entry.url, headers: { ...(entry.headers ?? {}) }, enabled }; + if (entry.type === "local" && Array.isArray(entry.command)) return { name, type: "local", command: [...entry.command], env: { ...(entry.environment ?? {}) }, enabled }; + return null; +} +export const toCursorEntry = toClaudeEntry; +// Cursor accepts a remote URL without an explicit transport selector. +export function fromCursorEntry(name, entry) { + if (!object(entry) || (entry.type !== undefined && !["http", "stdio"].includes(entry.type))) return null; + return fromClaudeEntry(name, entry.type === undefined && typeof entry.url === "string" ? { ...entry, type: "http" } : entry); +} +export const isSupportedMcpProvider = (provider) => ["claude", "codex", "opencode", "cursor"].includes(provider); + +export function jsonDocument(raw, strict) { + const errors = []; + const tree = parseTree(raw || "{}", errors, { allowTrailingComma: !strict, disallowComments: strict }); + const check = (node) => { + if (node.type === "object") { + const keys = node.children.map((p) => p.children[0].value); + if (new Set(keys).size !== keys.length) throw problem("unsupported", "Duplicate keys in the MCP configuration. Repair them before changing servers."); + } + for (const child of node.children ?? []) check(child); + }; + if (errors.length || tree?.type !== "object") throw problem("unsupported", "The MCP configuration is not a valid JSON object. Repair it before changing servers."); + check(tree); + return parse(raw || "{}", [], { allowTrailingComma: !strict, disallowComments: strict }); +} + +export function containerFor(provider, value) { + const key = provider === "opencode" ? "mcp" : "mcpServers"; + const root = value[key]; + if (root !== undefined && !object(root)) throw problem("unsupported", "The MCP configuration has an unsupported server section."); + // V1 may contain a real server named 'servers'. Its transport distinguishes + // that entry from V2's server dictionary. Never rewrite V2 back to V1. + const v2 = provider === "opencode" && ["servers", "timeout", "codemode", "protocol"].some((key) => + Object.hasOwn(root ?? {}, key) && !["local", "remote"].includes(root[key]?.type)); + if (v2 && root.servers !== undefined && !object(root.servers)) throw problem("unsupported", "The OpenCode V2 server dictionary must be an object."); + if (v2 && Object.keys(root).some((k) => !["servers", "timeout", "codemode", "protocol"].includes(k))) { + throw problem("unsupported", "OpenCode mixes V1 and V2 MCP entries. Repair its MCP section before changing servers."); + } + return { v2, keys: v2 ? [key, "servers"] : [key], entries: v2 ? root.servers ?? {} : root ?? {} }; +} + +async function fileFor(io, provider, home, env) { + if (provider === "opencode") return (await openCodeConfigPath(io, home, env)).file; + if (provider === "codex") return codexMcpPath(home, env); + if (provider === "claude") return claudeMcpPath(home, env); + if (provider === "cursor") return cursorMcpPath(home, env); + throw problem("unsupported", "MCP management is not supported for this provider."); +} + +// Unknown provider options must be shown as differing, rather than implying +// that cwd/auth/tool-policy differences are represented by the shared entry. +export function extraMcpOptions(provider, entry, v2 = false) { + const remote = typeof entry?.url === "string"; + const keys = provider === "codex" ? [...(remote ? ["url", "http_headers"] : ["command", "args", "env"]), "enabled"] + : provider === "opencode" ? [...(remote ? ["url", "headers"] : ["command", "environment"]), "type", v2 ? "disabled" : "enabled"] + : [...(remote ? ["url", "headers"] : ["command", "args", "env"]), "type"]; + return object(entry) ? Object.keys(entry).filter((k) => !keys.includes(k)) : []; +} +export const hasExtraMcpOptions = (provider, entry, v2 = false) => !object(entry) || extraMcpOptions(provider, entry, v2).length > 0; + +export async function readProviderMcp(io, provider, { home, env = {} }) { + const file = await fileFor(io, provider, home, env); + const snapshot = await readConfig(io, file); + let entries, v2 = false; + if (provider === "codex") entries = [...parseMcpToml(snapshot.content)]; + else { + const container = containerFor(provider, jsonDocument(snapshot.content, provider !== "opencode")); + entries = Object.entries(container.entries); v2 = container.v2; + } + const from = provider === "codex" ? fromCodexEntry : provider === "opencode" ? (name, entry) => fromOpenCodeEntry(name, entry, v2) : provider === "cursor" ? fromCursorEntry : fromClaudeEntry; + return { provider, path: file, present: snapshot.exists, revision: snapshot.revision, + servers: entries.map(([name, entry]) => { + const canonical = from(name, entry); + const malformed = !object(entry) + || ['enabled', 'disabled'].some(k => entry[k] !== undefined && typeof entry[k] !== 'boolean') + || (entry.args !== undefined && (!Array.isArray(entry.args) || !entry.args.every(a => typeof a === 'string'))) + || ['env', 'environment', 'headers', 'http_headers'].some(k => entry[k] !== undefined && + (!object(entry[k]) || ![null, Object.prototype].includes(Object.getPrototypeOf(entry[k])) || !Object.values(entry[k]).every(v => typeof v === 'string'))) + || (['claude', 'cursor'].includes(provider) && entry.type !== undefined && + !(canonical?.type === 'remote' ? ['http', 'streamable-http'] : ['stdio']).includes(entry.type)); + return { name, entry, canonical: !malformed && canonical && validateMcpServer(canonical).ok ? canonical : null, + extra: hasExtraMcpOptions(provider, entry, v2), extraOptions: extraMcpOptions(provider, entry, v2) }; + }) }; +} + +export async function writeProviderMcpEntry(io, provider, { home, env = {} }, name, entry, { revision } = {}) { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) throw problem("invalid", "Invalid MCP server name."); + const file = await fileFor(io, provider, home, env); + return changeConfig(io, file, revision, (raw) => { + if (provider === "codex") return applyMcpToml(raw, name, entry); + const value = jsonDocument(raw, provider !== "opencode"); + const container = containerFor(provider, value); + let nextEntry = entry; + if (entry && container.v2) { + const { enabled, ...rest } = entry; + nextEntry = { ...rest, disabled: enabled === false }; + } + if (!entry && !Object.hasOwn(container.entries, name)) return raw; + return applyEdits(raw || "{}\n", modify(raw || "{}\n", [...container.keys, name], nextEntry ?? undefined, + { formattingOptions: { insertSpaces: true, tabSize: 2, eol: raw.includes("\r\n") ? "\r\n" : "\n" } })); + }); +} diff --git a/docker/setup/mcp/files.mjs b/docker/setup/mcp/files.mjs new file mode 100644 index 0000000..5a1b625 --- /dev/null +++ b/docker/setup/mcp/files.mjs @@ -0,0 +1,48 @@ +// Share the native-settings queue with skill policy writers. Snapshots cover +// content, resolved target, permissions and file identity, with bounded reads. +import path from "node:path"; +import { randomBytes } from "node:crypto"; +import { configSnapshot, serializeState } from "../skills/state.mjs"; + +const conflict = () => Object.assign(new Error("Provider settings changed. Refresh MCP servers before applying this change."), { code: "conflict" }); +export async function readConfig(io, file) { + const value = await configSnapshot(file, io, true); + if (!value.exists) { + let ancestor = path.dirname(file), suffix = path.basename(file); + for (;;) { + try { value.resolvedPath = path.join(await io.realpath(ancestor), suffix); break; } + catch (e) { if (e.code !== "ENOENT") throw e; suffix = path.join(path.basename(ancestor), suffix); ancestor = path.dirname(ancestor); } + } + } + return { ...value, content: value.exists ? value.content : "" }; +} + +export async function changeConfig(io, file, revision, change) { + await io.mkdir(path.dirname(file), { recursive: true }); + const initial = await readConfig(io, file); + return serializeState(initial.resolvedPath, async () => { + const before = await readConfig(io, file); + if (initial.resolvedPath !== before.resolvedPath || (revision !== undefined && revision !== before.revision)) throw conflict(); + const content = change(before.content); + if (content === before.content) return { ok: true, path: file, revision: before.revision }; + if (Buffer.byteLength(content, "utf8") > 1024 * 1024) throw Object.assign(new Error("The MCP configuration would exceed the 1 MiB limit."), { code: "too_large" }); + const suffix = `${process.pid}.${randomBytes(8).toString("hex")}`; + const tmp = `${before.resolvedPath}.tmp.${suffix}`; + const backupPath = before.exists ? `${before.resolvedPath}.t3-mcp-backup.${suffix}` : null; + try { + const mode = before.mode & 0o600; + await io.writeFile(tmp, content, { encoding: "utf8", mode, flag: "wx" }); + await io.chmod(tmp, mode); + if ((await readConfig(io, file)).revision !== before.revision) throw conflict(); + if (backupPath) await io.writeFile(backupPath, before.content, { encoding: "utf8", mode: 0o600, flag: "wx" }); + if (before.exists) { + if ((await readConfig(io, file)).revision !== before.revision) throw conflict(); + await io.rename(tmp, before.resolvedPath); + } else { + try { await io.link(tmp, before.resolvedPath); } + catch (e) { if (e.code === "EEXIST") throw conflict(); throw e; } + } + return { ok: true, path: file, backupPath, revision: (await readConfig(io, file)).revision }; + } finally { await io.rm(tmp, { force: true }); } + }); +} diff --git a/docker/setup/mcp/parse.mjs b/docker/setup/mcp/parse.mjs new file mode 100644 index 0000000..ae7d5db --- /dev/null +++ b/docker/setup/mcp/parse.mjs @@ -0,0 +1,124 @@ +// Import data, never shell code. All formats converge on the same validator. +import Command from '../client/mcp-command.js'; +import { parseMcpToml } from './toml.mjs'; +import { jsonDocument, containerFor, fromCursorEntry, fromCodexEntry, fromOpenCodeEntry, extraMcpOptions } from './adapters.mjs'; +import { validateMcpServer } from './store.mjs'; + +export const SSE_MESSAGE = 'SSE-only servers aren’t portable. Use the server’s Streamable HTTP address.'; +const object = value => value && typeof value === 'object' && !Array.isArray(value); +const stripState = ({ enabled, ...server }) => server; +const fail = message => { throw new Error(message); }; +export function suggestMcpName(argvOrUrl) { + let name; + if (typeof argvOrUrl === 'string') { + const labels = new URL(argvOrUrl).hostname.split('.').slice(0, -1); + name = labels.find(label => !['www', 'mcp', 'api'].includes(label)) || 'server'; + } else { + const argv = argvOrUrl, exe = argv[0]?.split('/').at(-1) || ''; + if (['npx', 'bunx', 'uvx'].includes(exe) || (exe === 'pnpm' && argv[1] === 'dlx')) { + const args = argv.slice(exe === 'pnpm' ? 2 : 1); + name = args.find(arg => !arg.startsWith('-')); + } else if (exe === 'docker' && argv[1] === 'run') { + const valued = new Set(['-e', '--env', '-v', '--volume', '-p', '--publish', '--name', '--network', '-w', '--workdir']); + for (let at = 2; at < argv.length; at++) { + if (valued.has(argv[at])) { at++; continue; } + if (argv[at].startsWith('-')) continue; + name = argv[at].split('/').at(-1); break; + } + } else name = exe; + } + return (name || 'server').toLowerCase().replace(/^@[^/]+\//, '').replace(/[@:].*$/, '') + .replace(/^(mcp-server-|server-)/, '').replace(/(-mcp-server|-mcp|-server)$/, '') + .replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 64).replace(/-$/, '') || 'server'; +} + +function claudeCommand(words) { + let transport = 'stdio', name, target, command = [], env = {}, headers = {}; + for (let at = 3; at < words.length; at++) { + const word = words[at]; + if (word === '--') { command = words.slice(at + 1); break; } + if (['--transport', '-t', '--scope', '-s', '--env', '-e', '--header', '-H'].includes(word)) { + const value = words[++at]; if (value === undefined) fail('An option is missing its value.'); + if (['--transport', '-t'].includes(word)) transport = value; + if (['--env', '-e'].includes(word)) { + const split = value.indexOf('='); if (split < 1) fail('Use NAME=value for each environment variable.'); + const key = value.slice(0, split); if (Object.hasOwn(env, key)) fail('Environment names must be unique.'); + Object.defineProperty(env, key, { value: value.slice(split + 1), enumerable: true, configurable: true }); + } + if (['--header', '-H'].includes(word)) { + const split = value.indexOf(':'); if (split < 1) fail('Use Name: value for each header.'); + const key = value.slice(0, split).trim(); if (Object.hasOwn(headers, key)) fail('Header names must be unique.'); + Object.defineProperty(headers, key, { value: value.slice(split + 1).trimStart(), enumerable: true, configurable: true }); + } + } else if (word.startsWith('-') && !name) fail('That claude mcp add option isn’t supported here.'); + else if (!name) name = word; + else if (transport === 'http' || transport === 'sse') { if (target) fail('Use one server URL.'); target = word; } + else { command = words.slice(at); break; } + } + if (transport === 'sse') fail(SSE_MESSAGE); + if (!['http', 'stdio'].includes(transport)) fail('Choose stdio or HTTP transport.'); + if (!name) fail('Include a name after claude mcp add.'); + return transport === 'http' ? { name, type: 'remote', url: target, headers } : { name, type: 'local', command, env }; +} + +export function parseMcpSnippet(text) { + const result = { ok: true, format: 'command', servers: [], placeholders: [], notes: [], errors: [] }; + if (typeof text !== 'string') return { ...result, errors: ['Paste a server snippet as text.'] }; + if (Buffer.byteLength(text, 'utf8') > 64 * 1024) return { ...result, errors: ['That’s too long to be a server snippet.'] }; + text = text.trim(); if (!text) return result; + const add = (server, label = '', nativeEntry, provider, v2 = false) => { + try { + if (nativeEntry?.type === 'sse' || (server?.url && new URL(server.url).pathname.replace(/\/$/, '').endsWith('/sse'))) fail(SSE_MESSAGE); + if (server?.url) { const u = new URL(server.url); if (u.username || u.password) fail('Put credentials in a header, not in the URL.'); } + if (!server) fail('This entry’s format isn’t supported here.'); + if (nativeEntry && (['args'].some(key => nativeEntry[key] !== undefined && (!Array.isArray(nativeEntry[key]) || !nativeEntry[key].every(v => typeof v === 'string'))) + || ['env', 'environment', 'headers', 'http_headers'].some(key => nativeEntry[key] !== undefined && (!object(nativeEntry[key]) || !Object.values(nativeEntry[key]).every(v => typeof v === 'string'))))) fail('This entry’s format isn’t supported here.'); + const checked = validateMcpServer(server); if (!checked.ok) fail(checked.error); + result.servers.push(stripState(checked.server)); + for (const [prop, prefix] of [['env', 'env'], ['headers', 'header']]) for (const [key, value] of Object.entries(server[prop] || {})) { + if (Command.placeholder(value)) result.placeholders.push(prefix + '.' + key); + if (/\$\{[^}]+\}/.test(value)) result.notes.push('Variable references are resolved differently by each agent. Check them before starting a session.'); + } + if (nativeEntry && provider) { + const extra = extraMcpOptions(provider, nativeEntry, v2); + if (extra.length) result.notes.push('Native options not included: ' + extra.join(', ') + '.'); + } + } catch (error) { result.errors.push((label ? label + ': ' : '') + (error instanceof TypeError ? 'Use a valid HTTP server URL.' : error.message)); } + }; + try { + if (/^\s*\[\s*mcp_servers[.\s]/m.test(text)) { + result.format = 'codex-toml'; + let entries; try { entries = parseMcpToml(text); } catch { fail('Couldn’t read that as Codex TOML. Check that it’s complete.'); } + if (entries.size > 128) fail('Import at most 128 servers at a time.'); + for (const [name, entry] of entries) add(fromCodexEntry(name, entry), name, entry, 'codex'); + } else if (/^[{[]/.test(text)) { + result.format = 'json'; + let value; try { value = jsonDocument(text, false); } catch { fail('Couldn’t read that as JSON. Check that it’s complete.'); } + if (object(value.mcpServers)) { + result.format = 'claude-json'; + if (Object.keys(value.mcpServers).length > 128) fail('Import at most 128 servers at a time.'); + for (const [name, entry] of Object.entries(value.mcpServers)) add(fromCursorEntry(name, entry), name, entry, 'cursor'); + } else if (object(value.mcp)) { + result.format = 'opencode-json'; + const { entries, v2 } = containerFor('opencode', value); + if (Object.keys(entries).length > 128) fail('Import at most 128 servers at a time.'); + for (const [name, entry] of Object.entries(entries)) add(fromOpenCodeEntry(name, entry, v2), name, entry, 'opencode', v2); + } else { + const name = suggestMcpName(value.url || [value.command || 'server', ...(Array.isArray(value.args) ? value.args : [])]); + add(fromCursorEntry(name, value), '', value, 'cursor'); + } + } else if (/^https?:\/\/\S+$/.test(text)) { + result.format = 'url'; add({ name: suggestMcpName(text), type: 'remote', url: text, headers: {} }); + } else { + const words = Command.lex(text); + if (words.slice(0, 3).join(' ') === 'claude mcp add') { result.format = 'claude-mcp-add'; add(claudeCommand(words)); } + else { + add({ name: suggestMcpName(words), type: 'local', command: words, env: {} }); + if (words[0] === 'docker' && words[1] === 'run') for (let at = 2; at < words.length - 1; at++) + if (['-e', '--env'].includes(words[at]) && !words[at + 1].includes('=')) result.notes.push('Add ' + words[++at] + ' as an environment variable if it isn’t already set in the container.'); + } + } + } catch (error) { result.errors.push(error.message); } + result.placeholders = [...new Set(result.placeholders)]; result.notes = [...new Set(result.notes)]; + return result; +} diff --git a/docker/setup/mcp/store.mjs b/docker/setup/mcp/store.mjs new file mode 100644 index 0000000..46f785e --- /dev/null +++ b/docker/setup/mcp/store.mjs @@ -0,0 +1,234 @@ +// Shared MCP server store. Canonical JSON on the state volume; provider +// files are only touched by explicit link/unlink saves. +import path from "node:path"; +import { createHash, randomBytes } from "node:crypto"; + +export const MCP_PROVIDERS = Object.freeze({ + claude: "Claude Code", + codex: "Codex", + opencode: "OpenCode", + cursor: "Cursor", +}); +export const SUPPORTED_MCP_PROVIDERS = Object.freeze(Object.keys(MCP_PROVIDERS)); +// Keep native formats without a verified integration explicitly unavailable. +export const UNSUPPORTED_MCP_PROVIDERS = Object.freeze({ + antigravity: "Antigravity uses isolated T3 profiles; shared MCP settings are not integrated yet.", + grok: "Grok MCP management is not implemented. Grok may also load Claude and Cursor MCP settings through its native compatibility imports.", + muse: "Muse MCP management is not implemented yet.", +}); + +const MAX_STORE_BYTES = 256 * 1024; +const MAX_SERVERS = 128; +export const mcpDirFor = (baseDir) => path.join(baseDir, "setup", "mcp"); +export const mcpStorePathFor = (baseDir) => path.join(mcpDirFor(baseDir), "servers.json"); + +export const MCP_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const problem = (code, message) => Object.assign(new Error(message), { code }); + +export function validateMcpServer(input) { + const value = input && typeof input === "object" ? input : null; + if (!value) return { ok: false, error: "Expected an MCP server object." }; + const name = String(value.name ?? "").trim(); + if (!MCP_NAME_RE.test(name) || name.length > 64) return { ok: false, error: "Use a server name of 1–64 lowercase letters, numbers and single hyphens." }; + const type = value.type === "remote" ? "remote" : value.type === "local" ? "local" : null; + if (!type) return { ok: false, error: "Choose a local or remote server type." }; + const enabled = value.enabled === undefined ? true : value.enabled; + if (typeof enabled !== "boolean") return { ok: false, error: "Expected enabled to be a boolean." }; + const out = { name, type, enabled }; + if (type === "local") { + const command = Array.isArray(value.command) ? value.command : null; + if (!command || command.length < 1 || command.length > 32 || !command[0] || !command.every((part) => typeof part === "string" && part.length <= 1024)) { + return { ok: false, error: "A local server needs an executable and at most 31 arguments, each up to 1024 characters." }; + } + if (command.some((part) => part.includes("\0"))) return { ok: false, error: "Command entries must not contain NUL bytes." }; + out.command = [...command]; + const env = value.env === undefined ? {} : value.env; + if (!env || typeof env !== "object" || Array.isArray(env)) return { ok: false, error: "Expected env to be an object." }; + const keys = Object.keys(env); + if (keys.length > 64) return { ok: false, error: "A server holds at most 64 environment entries." }; + for (const key of keys) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.length > 128) return { ok: false, error: `Invalid environment name: ${key}` }; + if (typeof env[key] !== "string" || env[key].length > 8192 || env[key].includes("\0")) return { ok: false, error: `Invalid environment value for ${key}.` }; + } + out.env = Object.fromEntries(keys.sort().map((key) => [key, String(env[key])])); + } else { + const url = String(value.url ?? "").trim(); + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname || url.length > 2048 || /[\r\n\0]/.test(url)) throw new Error(); + if (parsed.username || parsed.password) return { ok: false, error: "Put remote credentials in Headers, not in the URL." }; + } catch { return { ok: false, error: "A remote server needs a valid http(s) URL of at most 2048 characters." }; } + out.url = url; + const headers = value.headers === undefined ? {} : value.headers; + if (!headers || typeof headers !== "object" || Array.isArray(headers)) return { ok: false, error: "Expected headers to be an object." }; + const keys = Object.keys(headers); + if (new Set(keys.map((key) => key.toLowerCase())).size !== keys.length) return { ok: false, error: "Header names must be unique regardless of letter case." }; + if (keys.length > 64) return { ok: false, error: "A server holds at most 64 header entries." }; + for (const key of keys) { + if (!/^[A-Za-z0-9-]+$/.test(key) || key.length > 128) return { ok: false, error: `Invalid header name: ${key}` }; + if (typeof headers[key] !== "string" || headers[key].length > 8192 || /[\r\n\0]/.test(headers[key])) return { ok: false, error: `Invalid header value for ${key}.` }; + } + out.headers = Object.fromEntries(keys.sort().map((key) => [key, String(headers[key])])); + } + return { ok: true, server: out }; +} + +const canonicalServer = (server) => JSON.stringify(server); +export const mcpRevision = (servers) => createHash("sha256").update(JSON.stringify([...servers].sort((a, b) => a.name.localeCompare(b.name)))).digest("hex"); + +async function readStoreFile(fs, file) { + let stat; + try { stat = await fs.lstat(file); } + catch (error) { if (error?.code === "ENOENT") return { present: false }; throw error; } + if (!stat.isFile()) throw problem("invalid", "The shared MCP store must be a regular file."); + if (stat.size > MAX_STORE_BYTES) throw problem("too_large", "The shared MCP store exceeds the 256 KiB limit. Reduce it directly on disk."); + const handle = await fs.open(file, "r"); + let raw; + try { + const buf = Buffer.alloc(MAX_STORE_BYTES + 1); let bytes = 0; + while (bytes < buf.length) { const r = await handle.read(buf, bytes, buf.length - bytes, bytes); if (!r.bytesRead) break; bytes += r.bytesRead; } + if (bytes > MAX_STORE_BYTES) throw problem("too_large", "The shared MCP store exceeds the 256 KiB limit."); + raw = buf.toString("utf8", 0, bytes); + } finally { await handle.close(); } + let parsed; + try { parsed = JSON.parse(raw); } + catch { throw problem("invalid", "The shared MCP store is not valid JSON. Repair it directly on disk."); } + const list = Array.isArray(parsed) ? parsed : parsed?.servers; + if (!Array.isArray(list)) throw problem("invalid", "The shared MCP store must hold a server list."); + if (list.length > MAX_SERVERS) throw problem("too_large", "The shared MCP store holds too many servers."); + const servers = []; + for (const entry of list) { + const checked = validateMcpServer(entry); + if (!checked.ok) throw problem("invalid", "The shared MCP store holds an invalid server: " + checked.error); + servers.push(checked.server); + } + const names = servers.map((server) => server.name); + if (new Set(names).size !== names.length) throw problem("invalid", "The shared MCP store holds duplicate server names."); + return { present: true, servers, bytes: Buffer.byteLength(raw, "utf8") }; +} + +async function writeFileAtomic(fs, file, text) { + if (Buffer.byteLength(text, "utf8") > MAX_STORE_BYTES) throw problem("too_large", "The shared MCP store would exceed the 256 KiB limit. Reduce the server definition."); + await fs.mkdir(path.dirname(file), { recursive: true }); + const tmp = `${file}.tmp.${process.pid}.${randomBytes(6).toString("hex")}`; + try { + await fs.writeFile(tmp, text, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(tmp, 0o600); + await fs.rename(tmp, file); + } finally { + await fs.rm(tmp, { force: true }); + } +} + +const storeQueues = new Map(); +export async function withStore(file, work) { + const key = path.resolve(file); + const previous = storeQueues.get(key) ?? Promise.resolve(); + const current = previous.catch(() => {}).then(work); + storeQueues.set(key, current); + try { return await current; } + finally { if (storeQueues.get(key) === current) storeQueues.delete(key); } +} + +export async function readMcpStore(fs, baseDir) { + const file = mcpStorePathFor(baseDir); + try { + const read = await readStoreFile(fs, file); + if (!read.present) return { present: false, path: file, servers: [], revision: mcpRevision([]), bytes: 0, error: null }; + return { present: read.servers.length > 0, path: file, servers: read.servers, revision: mcpRevision(read.servers), bytes: read.bytes, error: null }; + } catch (error) { + return { present: error?.code === "too_large", path: file, servers: [], revision: null, bytes: 0, + code: error?.code, error: String(error?.message ?? error) }; + } +} + +const changedStore = () => ({ ok: false, code: "conflict", error: "Shared MCP servers changed since you opened them. Reopen the editor to load the current list." }); + +export async function createMcpServer(fs, baseDir, input) { + const checked = validateMcpServer(input); + if (!checked.ok) return checked; + const file = mcpStorePathFor(baseDir); + return withStore(file, async () => { + const read = await readMcpStore(fs, baseDir); + if (read.error) return { ok: false, error: read.error }; + if (read.servers.some((server) => server.name === checked.server.name)) { + return { ok: false, code: "conflict", error: "A shared MCP server with that name already exists." }; + } + const servers = [...read.servers, checked.server]; + if (servers.length > MAX_SERVERS) throw problem("too_large", "The shared MCP store holds at most 128 servers."); + await writeFileAtomic(fs, file, JSON.stringify({ version: 1, servers }, null, 2) + "\n"); + return { ok: true, path: file, created: !read.present && read.servers.length === 0, revision: mcpRevision(servers), server: checked.server }; + }); +} + +export async function saveMcpServer(fs, baseDir, name, revision, input) { + if (input?.name !== undefined && input.name !== name) return { ok: false, error: "The server name cannot be changed. Add it under the new name instead." }; + const checked = validateMcpServer({ ...(input ?? {}), name }); + if (!checked.ok) return checked; + if (checked.server.name !== String(name ?? "").trim()) return { ok: false, error: "The server name cannot be changed. Remove it and add a new one." }; + const file = mcpStorePathFor(baseDir); + return withStore(file, async () => { + const read = await readMcpStore(fs, baseDir); + if (read.error) return { ok: false, error: read.error }; + if (read.revision !== revision) return changedStore(); + const at = read.servers.findIndex((server) => server.name === checked.server.name); + if (at < 0) return { ok: false, code: "missing", error: "This shared MCP server no longer exists. Refresh the list." }; + const servers = read.servers.map((server, index) => index === at ? checked.server : server); + await writeFileAtomic(fs, file, JSON.stringify({ version: 1, servers }, null, 2) + "\n"); + return { ok: true, path: file, revision: mcpRevision(servers), server: checked.server }; + }); +} + +export async function removeMcpServer(fs, baseDir, name, revision, beforeRemove = async () => {}) { + const file = mcpStorePathFor(baseDir); + return withStore(file, async () => { + const read = await readMcpStore(fs, baseDir); + if (read.error) return { ok: false, error: read.error }; + if (read.revision !== revision) return changedStore(); + if (!read.servers.some((server) => server.name === name)) return { ok: true, path: file, deleted: false, revision: read.revision }; + await beforeRemove(); + const servers = read.servers.filter((server) => server.name !== name); + if (servers.length === 0) await fs.rm(file, { force: true }); + else await writeFileAtomic(fs, file, JSON.stringify({ version: 1, servers }, null, 2) + "\n"); + return { ok: true, path: file, deleted: true, removed: name, revision: mcpRevision(servers) }; + }); +} + +export function redactMcpServer(server) { + const { env, headers, enabled, ...rest } = server; + // Paths and query strings may contain signed credentials. Only the origin + // belongs in polling/list summaries; the full URL loads with the editor. + if (rest.url) { try { rest.url = new URL(rest.url).origin; } catch { rest.url = "(invalid URL)"; } } + return { ...rest, + envKeys: Object.keys(env ?? {}), + envCount: Object.keys(env ?? {}).length, + headerKeys: Object.keys(headers ?? {}), + headerCount: Object.keys(headers ?? {}).length, + secretCount: Object.keys(env ?? {}).length + Object.keys(headers ?? {}).length, + }; +} + +// Read the source while holding the store queue. Adopting never writes the +// source agent; its revision is checked again just before the atomic commit. +export async function adoptMcpServer(fs, baseDir, name, revision, source, replace = false) { + return withStore(mcpStorePathFor(baseDir), async () => { + const read = await readMcpStore(fs, baseDir); + if (read.error) throw problem(read.code || "unavailable", read.error); + if (read.revision !== revision) throw problem("conflict", changedStore().error); + const exists = read.servers.some(s => s.name === name); + if (exists !== replace) throw problem("conflict", exists ? "A shared MCP server with that name already exists." : "The shared MCP server no longer exists."); + const loaded = await source(); + const { enabled, ...definition } = loaded.server; + const checked = validateMcpServer({ ...definition, name }); + if (!checked.ok) throw problem("unsupported", "This entry’s format isn’t supported here."); + const servers = exists ? read.servers.map(s => s.name === name ? checked.server : s) : [...read.servers, checked.server]; + if (servers.length > MAX_SERVERS) throw problem("too_large", "The shared MCP store holds at most 128 servers."); + await loaded.verify(); + await writeFileAtomic(fs, mcpStorePathFor(baseDir), JSON.stringify({ version: 1, servers }, null, 2) + "\n"); + return { ok: true, server: redactMcpServer(checked.server), revision: mcpRevision(servers), dropped: loaded.dropped }; + }); +} + +// Fingerprint of the translated provider entry, so link state is derived by +// comparison instead of a new manifest format. +export const mcpFingerprint = (value) => createHash("sha256").update(canonicalServer(value)).digest("hex"); diff --git a/docker/setup/mcp/toml.mjs b/docker/setup/mcp/toml.mjs new file mode 100644 index 0000000..510dc23 --- /dev/null +++ b/docker/setup/mcp/toml.mjs @@ -0,0 +1,77 @@ +// Parse and patch the complete TOML syntax tree. The vendor keeps untouched +// comments/formatting; never scan table boundaries with regular expressions. +import { parse, patch, stringify, TomlDocument } from "../vendor/toml-patch.mjs"; +import { isDeepStrictEqual } from "node:util"; + +const problem = () => Object.assign(new Error("Codex config.toml cannot be safely edited. Check its TOML syntax before changing MCP servers."), { code: "unsupported" }); +const object = (v) => v && typeof v === "object" && !Array.isArray(v); +const plain = (v) => Array.isArray(v) ? v.map(plain) + : v && Object.getPrototypeOf(v) === null ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)])) : v; +function document(text) { + try { + const value = parse(String(text ?? "")); + if (value.mcp_servers !== undefined && !object(value.mcp_servers)) throw problem(); + return plain(value); + } catch { throw problem(); } // Parser errors can contain credential values. +} + +export function parseMcpToml(text) { + return new Map(Object.entries(document(text).mcp_servers ?? {})); +} + +export function applyMcpToml(text, name, entry) { + const value = document(text); + if (entry === null && !Object.hasOwn(value.mcp_servers ?? {}, name)) return text; + const servers = { ...(value.mcp_servers ?? {}) }; + if (entry === null) delete servers[name]; + else servers[name] = entry; + const desired = { ...value, mcp_servers: servers }; + if (!Object.keys(servers).length) delete desired.mcp_servers; + try { + const tree = new TomlDocument(text).cst; + const starts = [0]; + for (let at = text.indexOf("\n"); at !== -1; at = text.indexOf("\n", at + 1)) starts.push(at + 1); + const lineSpan = (node) => { + const start = starts[node.loc.start.line - 1]; + const end = starts[node.loc.end.line] ?? text.length; + return [start, end]; + }; + const owns = (keys) => keys[0] === "mcp_servers" && keys[1] === name; + const edits = []; + let inlineRoot = false; + for (const node of tree) { + if (node.type === "KeyValue") { + if (owns(node.key.value)) edits.push([...lineSpan(node), ""]); + else if (node.key.value.length === 1 && node.key.value[0] === "mcp_servers") { + // Patch an inline root locally, never the surrounding document. + const [start, end] = lineSpan(node); + const updated = desired.mcp_servers ? { mcp_servers: desired.mcp_servers } : {}; + edits.push([start, end, patch(text.slice(start, end), updated)]); + inlineRoot = true; + } + } else if (node.type === "Table" || node.type === "TableArray") { + const keys = node.key.item.value; + if (owns(keys)) { + // The parser attaches comments before the next table to this one. + // End at its last value, preserving those unrelated trailing notes. + const rows = node.items.filter((item) => item.type === "KeyValue"); + edits.push([lineSpan(node.key)[0], lineSpan(rows.at(-1) ?? node.key)[1], ""]); + } else if (keys.length === 1 && keys[0] === "mcp_servers") { + for (const row of node.items) if (row.type === "KeyValue" && row.key.value[0] === name) edits.push([...lineSpan(row), ""]); + } + } + } + let next = text; + for (const [start, end, replacement] of edits.sort((a, b) => b[0] - a[0])) next = next.slice(0, start) + replacement + next.slice(end); + if (entry !== null && !inlineRoot) { + const eol = text.includes("\r\n") ? "\r\n" : "\n"; + // Remove only the known, generated parent marker, not user source. + const generated = stringify({ mcp_servers: { [name]: entry } }, { inlineTableStart: 3, newLine: eol }).replace(/^\[mcp_servers\]\r?\n(?:\r?\n)?/, ""); + next += (next.endsWith("\n") || !next ? "" : eol) + eol + generated; + } + const actual = document(next); + if (actual.mcp_servers && !Object.keys(actual.mcp_servers).length) delete actual.mcp_servers; + if (!isDeepStrictEqual(actual, desired)) throw problem(); + return next; + } catch { throw problem(); } +} diff --git a/docker/setup/page.mjs b/docker/setup/page.mjs index 168049a..8593fec 100644 --- a/docker/setup/page.mjs +++ b/docker/setup/page.mjs @@ -35,6 +35,11 @@ export const CONSOLE_SCRIPTS = [ "client/skill-inspector.js", "client/skill-import.js", "client/skills.js", + "client/mcp-command.js", + "client/mcp-model.js", + "client/mcp-inspector.js", + "client/mcp-editor.js", + "client/mcp.js", "app.js", ]; export const UNLOCK_SCRIPTS = ["client/base.js", "design/ui.js", "client/unlock.js"]; @@ -153,7 +158,7 @@ export function renderConsole({ assets, nonce, mount, embed = false }) { const tabs = ROUTES.TABS.map((tab) => `${escapeHtml(tab.title)}`).join(""); const pages = [...ROUTES.PAGES, ROUTES.page("more")].map((page) => - ``).join("\n"); + ``).join("\n"); const foot = embed ? `Open in a new tab` : `Open T3 Code diff --git a/docker/setup/server.mjs b/docker/setup/server.mjs index b8b76f6..2ca06d4 100644 --- a/docker/setup/server.mjs +++ b/docker/setup/server.mjs @@ -33,6 +33,7 @@ import { createDeviceLabels } from "./device-labels.mjs"; import * as Context from "./context.mjs"; import { createOpenCodeAuth } from "./opencode-auth.mjs"; import { createSkillsService } from "./skills.mjs"; +import { createMcpService } from "./mcp.mjs"; import { clientAddress } from "./client-address.mjs"; import { newKey, readKeyFile, writeKeyFile } from "./setup-key.mjs"; import { connectState, jsonFrom, parseLinkOutput } from "./connect.mjs"; @@ -519,6 +520,9 @@ const contextFs = async () => (await import("node:fs/promises")); const skillsService = createSkillsService({ home: CONTEXT_HOME, env: process.env, getHarness: async (provider) => (await harnessCache.snapshotCheap()).harnesses?.find((row) => row.id === provider), }); +const mcpService = createMcpService({ home: CONTEXT_HOME, env: process.env, baseDir: STATE_DIR, + io: await contextFs(), +}); // The browser image's agent browser, as built: the versions are build args the // Dockerfile bakes into the environment. Absent on the core image. @@ -912,7 +916,14 @@ const status = async () => { // Every other tool in the global mise config, added here or with `mise use -g`. packages: withLatest("package", packageRows), // Global instruction metadata; the editor fetches contents on demand. - context: await attempt("context", async () => Context.contextStatus(await contextFs(), { baseDir: STATE_DIR, home: CONTEXT_HOME, env: process.env }), { + // Shared MCP metadata rides alongside; server entries stay server-side. + context: await attempt("context", async () => { + const [instructions, mcp] = await Promise.all([ + Context.contextStatus(await contextFs(), { baseDir: STATE_DIR, home: CONTEXT_HOME, env: process.env }), + mcpService.status().catch((error) => ({ ok: false, error: String(error?.message ?? error) })), + ]); + return { ...instructions, mcp }; + }, { agentsMd: { present: false, bytes: 0, error: "unavailable" }, }), // The background install of everything T3_PREINSTALL names, on a first @@ -2153,7 +2164,8 @@ const ROUTES = ["/login", "/logout", "/hello", "/status", "/pair", "/revoke", "/ "/providers", "/updates/check", "/packages/install", "/packages/update", "/packages/uninstall", "/packages/cancel", "/packages/registry", "/packages/versions", "/packages/info", - "/context/agents-md", "/context/skills/reach", "/context/skills/read", "/context/skills/create", "/context/skills/save", "/context/skills/remove", "/context/skills/state", "/context/skills/source", "/context/skills/plan", "/context/skills/install", "/context/skills/discard", "/context/skills"]; + "/context/agents-md", "/context/skills/reach", "/context/skills/read", "/context/skills/create", "/context/skills/save", "/context/skills/remove", "/context/skills/state", "/context/skills/source", "/context/skills/plan", "/context/skills/install", "/context/skills/discard", "/context/skills", + "/context/mcp", "/context/mcp/read", "/context/mcp/create", "/context/mcp/save", "/context/mcp/remove", "/context/mcp/link", "/context/mcp/diff", "/context/mcp/adopt", "/context/mcp/parse"]; // One background pass for the opt-in auto-updater. Checks are cheap and // always safe; installs run only when enabled, an update is known, and @@ -2689,6 +2701,50 @@ const server = createServer(async (req, res) => { } } + // --- shared MCP servers --------------------------------------------- + // Inventory per provider is on demand, never part of /status polling; + // /status carries only redacted shared metadata and link states. + if (req.method === "GET" && ["/context/mcp", "/context/mcp/read", "/context/mcp/diff"].includes(route)) { + try { + const params = raw.searchParams; + if (route === "/context/mcp/diff") return sendJson(res, 200, await mcpService.diff(params.get("provider"), params.get("name"))); + if (route === "/context/mcp/read") { + if (!params.get("name")) return sendJson(res, 400, { ok: false, code: "invalid", error: "Expected an MCP server name." }); + return sendJson(res, 200, await mcpService.read(params.get("name"), params.get("provider"))); + } + if (params.get("provider")) return sendJson(res, 200, await mcpService.list(params.get("provider"))); + return sendJson(res, 200, await mcpService.status({ refresh: params.get("refresh") === "1" })); + } catch (error) { + const status = { invalid: 400, missing: 404, ENOENT: 404, conflict: 409, too_large: 413, unsupported: 403, EACCES: 403, EPERM: 403 }[error?.code] || 500; + return sendJson(res, status, { ok: false, code: error?.code || "unavailable", error: String(error?.message || "MCP discovery is unavailable.").slice(0, 300) }); + } + } + + if (req.method === "POST" && ["/context/mcp/create", "/context/mcp/save", "/context/mcp/remove", "/context/mcp/link", "/context/mcp/adopt", "/context/mcp/parse"].includes(route)) { + try { + const input = JSON.parse((await readBody(req, 6 * 64 * 1024 + 4096)) || "{}"); + if (!input || typeof input !== "object" || Array.isArray(input)) return sendJson(res, 400, { ok: false, error: "Expected an MCP operation object." }); + if (route === "/context/mcp/parse") { + if (typeof input.text !== "string") return sendJson(res, 400, { ok: false, error: "Paste a server snippet as text." }); + if (Buffer.byteLength(input.text, "utf8") > 64 * 1024) return sendJson(res, 413, { ok: false, error: "That’s too long to be a server snippet." }); + return sendJson(res, 200, mcpService.parse(input.text)); + } + if (["/context/mcp/link", "/context/mcp/adopt"].includes(route) && typeof input.providerRevision !== "string") return sendJson(res, 400, { ok: false, code: "invalid", error: "Refresh MCP servers before changing provider settings." }); + const result = route === "/context/mcp/link" ? await mcpService.setLink(input.provider, input.name, input.linked, input.sharedRevision, input.providerRevision, input.enabled) + : route === "/context/mcp/adopt" ? await mcpService.adopt(input.provider, input.name, input.providerRevision, input.sharedRevision, input.replace) + : route === "/context/mcp/create" ? await mcpService.create(input.server) + : route === "/context/mcp/save" ? await mcpService.save(input.name, input.revision, input.server) + : await mcpService.remove(input.name, input.revision); + recordEvent("context.saved", route === "/context/mcp/link" + ? `${input.provider} ${input.linked ? "uses" : "stopped using"} shared MCP server ${input.name}` + : route === "/context/mcp/remove" ? "Removed a shared MCP server" : "Saved a shared MCP server"); + return sendJson(res, 200, result); + } catch (error) { + const status = { invalid: 400, missing: 404, ENOENT: 404, conflict: 409, EEXIST: 409, too_large: 413, unsupported: 403, EACCES: 403, EPERM: 403 }[error?.code] || (error instanceof SyntaxError ? 400 : 500); + return sendJson(res, status, { ok: false, code: error?.code || "unavailable", error: error instanceof SyntaxError ? "Use a valid MCP operation object." : String(error?.message || "The MCP operation failed.").slice(0, 500) }); + } + } + // --- global instructions ---------------------------------------------- if (req.method === "GET" && route === "/context/agents-md") { try { diff --git a/docker/setup/vendor/README.md b/docker/setup/vendor/README.md index 966b78e..8fb394e 100644 --- a/docker/setup/vendor/README.md +++ b/docker/setup/vendor/README.md @@ -41,3 +41,28 @@ npm install --prefix /tmp/jsonc-build --no-audit --no-fund jsonc-parser@3.3.1 es Bundle SHA-256: `bd0f3ce191c835fbd222afb0e0e416852d0ea1c917406bdd0a609a0b8655f5ef`. Inputs are bounded to 1 MiB; invalid syntax, duplicate keys and unsupported policy shapes disable controls rather than normalizing away existing settings. + +## TOML syntax tree + +`toml-patch.mjs` is the unmodified ESM distribution of +`@decimalturn/toml-patch@3.1.2` (MIT; see `toml-patch.LICENSE`). +Upstream: https://github.com/DecimalTurn/toml-patch + +```sh +npm pack @decimalturn/toml-patch@3.1.2 +tar -xzf decimalturn-toml-patch-3.1.2.tgz +cp package/dist/toml-patch.js toml-patch.mjs +cp package/LICENSE toml-patch.LICENSE +``` + +Archive SHA-256: `aed417934ced4cd4d19da54eb286c16750ba9058ca2e47be4162e3c0e5449514`. +Distribution SHA-256: `8872b6d74fd090a2e358b727ce68d055af802c9efa86d05a8a1d05cdba6ce869`. + +MCP editing validates the complete document and uses this pinned parser's CST +locations to change only the selected server. Unrelated source, including +comments attached to the end of a preceding table, stays in place. Inline-root +edits are patched only inside that root assignment. The result is parsed again +and checked against the intended data before publication. Nested tables, +arrays of tables and multiline strings are covered by regression tests. Update +the distribution, license, checksums and CST compatibility checks together. +There is no runtime package installation and the bundle never reaches browsers. diff --git a/docker/setup/vendor/toml-patch.LICENSE b/docker/setup/vendor/toml-patch.LICENSE new file mode 100644 index 0000000..a8c55ea --- /dev/null +++ b/docker/setup/vendor/toml-patch.LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2019–2025 Tim Hall +Copyright (c) 2025 Martin Leduc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker/setup/vendor/toml-patch.mjs b/docker/setup/vendor/toml-patch.mjs new file mode 100644 index 0000000..4cbaab6 --- /dev/null +++ b/docker/setup/vendor/toml-patch.mjs @@ -0,0 +1,56 @@ +//! @decimalturn/toml-patch v3.1.2 - https://github.com/DecimalTurn/toml-patch - @license: MIT +function e(e){return e.type===`Document`}function t(e){return e.type===`Table`}function n(e){return e.type===`TableKey`}function r(e){return e.type===`TableArray`}function i(e){return e.type===`TableArrayKey`}function a(e){return e.type===`KeyValue`}function o(e){return e.type===`String`}function s(e){return e.type===`Float`}function c(e){return e.type===`DateTime`}function l(e){return e.type===`InlineArray`}function u(e){return e.type===`InlineItem`}function d(e){return e.type===`InlineTable`}function f(e){return e.type===`Comment`}function p(n){return e(n)||t(n)||r(n)||d(n)||l(n)}function m(e){return n(e)||i(e)||u(e)}function h(e){return a(e)||t(e)||r(e)||f(e)}let g,_;function v(e){if(g===e&&_!==void 0)return _;let t=C(e);return g=e,_=t,t}function y(){g=void 0,_=void 0}function b(e){return{lines:e.end.line-e.start.line+1,columns:e.end.column-e.start.column}}function x(e,t){let n=Array.isArray(e)?e:C(e);if(n.length===0)return{line:1,column:t};let r=0,i=n.length-1;for(;r>>1;n[e]({start:x(a,e),end:x(a,t)});for(;r=n||e.charCodeAt(r+1)!==10))throw new D(e,x(a,r),`Standalone CR; must be CRLF or LF`);if(t!==32&&t!==9&&t!==13){if(t===10)s(r);else if(t===91||t===93)yield{type:`Bracket`,raw:e[r],loc:o(r,r+1)};else if(t===123||t===125)yield{type:`Curly`,raw:e[r],loc:o(r,r+1)};else if(t===61)yield{type:`Equal`,raw:`=`,loc:o(r,r+1)};else if(t===44)yield{type:`Comma`,raw:`,`,loc:o(r,r+1)};else if(t===46)yield{type:`Dot`,raw:`.`,loc:o(r,r+1)};else if(t===35)yield c();else{let t=ne(e,r,`'`)||ne(e,r,`"`);t?yield l(t):yield u()}}r++}function s(t){a.push(t);let n=t>0&&e.charCodeAt(t-1)===13?`\r +`:` +`;i.reference===null?i.reference=n:i.reference!==n&&(i.mixed=!0)}function c(){let t=r;for(;r+1=6)throw new D(e,x(a,r),`Invalid multiline string: ${n} consecutive ${t} characters`);if(n===3){r+=2;break}r+=n-3,r+=2;break}let i=e.charCodeAt(r);if(i===13&&(r+1>=n||e.charCodeAt(r+1)!==10))throw new D(e,x(a,r),`Standalone CR in multiline string; must be CRLF or LF`);if((i<=31||i===127)&&i!==9&&i!==10&&i!==13){let n=t===`"`?`multiline basic strings`:`multiline literal strings`,o=`0x${i.toString(16).toUpperCase().padStart(2,`0`)}`,s=``;i===0?s=`Null`:i===127&&(s=`DEL`);let c=s?`${s} (${o}) not allowed in ${n}`:`Control char ${o} not allowed in ${n}`;throw new D(e,x(a,r),c)}i===10&&s(r),r++}if(r>=n){if(t===`"`){let t=0,i=n-1;for(;i>=0&&e[i]===`\\`;)t++,i--;if(t>0&&t%2!=0)throw new D(e,x(a,r),`Unterminated multiline ${c} (possible escape issue)`)}throw new D(e,x(a,r),`Unterminated multiline ${c}`)}return{type:`Literal`,raw:e.slice(i,r+1),loc:o(i,r+1)}}function u(){let t=e[r];if(!ee.test(t))throw new D(e,x(a,r),`Unexpected char "${t}"`);let i=r,s=t===`"`,c=t===`'`;for(;r=n);){let t=e.charCodeAt(r+1);if(!(s||c)&&(t===32||t===9||t===10||t===13||t===44||t===46||t===93||t===125||t===61||t===35))break;if(r++,!(s||c)){let t=e.charCodeAt(r);if(t<=31||t===127)throw new D(e,x(a,r),`Control char 0x${t.toString(16).toUpperCase().padStart(2,`0`)} not allowed in unquoted value`)}if(s||c){let t=e.charCodeAt(r);if((t<=31||t===127)&&t!==9){let n=s?`basic strings`:`literal strings`,i=`0x${t.toString(16).toUpperCase().padStart(2,`0`)}`,o=``;t===10?o=`Newline`:t===13?o=`Carriage return`:t===0?o=`Null`:t===127&&(o=`DEL`);let c=o?`${o} (${i}) not allowed in ${n}`:`Control char ${i} not allowed in ${n}`;throw new D(e,x(a,r),c)}}let i=e[r];if(i===`"`&&!c&&(s=!s),i===`'`&&!s&&(c=!c),r+1>=n)break;if(s&&i===`\\`){let t=e[r+1];(t===`"`||t===`\\`)&&r++}}if(s||c)throw new D(e,x(a,i),`Expected close of string with ${s?`"`:`'`}`);return{type:`Literal`,raw:e.slice(i,r+1),loc:o(i,r+1)}}}function ne(e,t,n){if(!n||e[t]!==n||e[t+1]!==n||e[t+2]!==n)return!1;if(n===`'`)return n;let r=0,i=t-1;for(;i>=0&&e[i]===`\\`;)r++,i--;return(r===0||r%2==0)&&n}const j=new Int8Array(128);j.fill(-1);for(let e=0;e<10;e++)j[48+e]=e;for(let e=0;e<6;e++)j[65+e]=10+e,j[97+e]=10+e;function M(e){let t=e.charCodeAt(0);return t<128&&j[t]!==-1}function re(e,t){return j[e.charCodeAt(t)]<<12|j[e.charCodeAt(t+1)]<<8|j[e.charCodeAt(t+2)]<<4|j[e.charCodeAt(t+3)]}function ie(e){return e.startsWith(`'''`)?oe(e.slice(3,e.length-3)):e.startsWith(`'`)?e.slice(1,e.length-1):e.startsWith(`"""`)?ae(oe(e.slice(3,e.length-3)),!0):e.startsWith(`"`)?ae(e.slice(1,e.length-1),!1):e}function ae(e,t){let n=e.length;if(!t&&e.indexOf(`\\`)===-1)return e;let r=[],i=0;for(let a=0;ai&&r.push(e.slice(i,a)),a++,a>=n)throw Error(`Trailing backslash`);let o=e[a];switch(o){case`b`:r.push(`\b`);break;case`t`:r.push(` `);break;case`n`:r.push(` +`);break;case`f`:r.push(`\f`);break;case`r`:r.push(`\r`);break;case`"`:r.push(`"`);break;case`\\`:r.push(`\\`);break;case`e`:r.push(`\x1B`);break;case`u`:{if(a+4>n||!M(e[a+1])||!M(e[a+2])||!M(e[a+3])||!M(e[a+4]))throw Error(`Invalid Unicode escape: \\u${e.slice(a+1,a+5)}`);let t=re(e,a+1);if(t>=55296&&t<=57343)throw Error(`Invalid \\u${e.slice(a+1,a+5)}: surrogates not allowed`);r.push(String.fromCharCode(t)),a+=4;break}case`U`:{if(a+8>n)throw Error(`Invalid Unicode escape: \\U${e.slice(a+1,a+9)}`);for(let t=1;t<=8;t++)if(!M(e[a+t]))throw Error(`Invalid Unicode escape: \\U${e.slice(a+1,a+9)}`);let t=parseInt(e.slice(a+1,a+9),16);if(t>=55296&&t<=57343)throw Error(`Invalid \\U${e.slice(a+1,a+9)}: surrogates not allowed`);r.push(String.fromCodePoint(t)),a+=8;break}case`x`:{if(a+2>n||!M(e[a+1])||!M(e[a+2]))throw Error(`Invalid hex escape: \\x${e.slice(a+1,a+3)}`);let t=j[e.charCodeAt(a+1)]<<4|j[e.charCodeAt(a+2)];r.push(String.fromCharCode(t)),a+=2;break}default:if(t&&(o===` +`||o===`\r`||o===` `||o===` `)){let t=o===` +`||o===`\r`,r=a;for(;r0?`.`+String(e).padStart(3,`0`).replace(/0+$/,``):``}function me(e){if(e===`Z`)return 0;let t=e[0]===`+`?1:-1,[n,r]=e.slice(1).split(`:`);return t*(parseInt(n)*60+parseInt(r))}var he=class e{static IS_DATE_ONLY=/^\d{4}-\d{2}-\d{2}$/;static IS_TIME_ONLY=/^\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;static IS_LOCAL_DATETIME_T=/^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;static IS_LOCAL_DATETIME_SPACE=/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;static IS_OFFSET_DATETIME_T=/^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;static IS_OFFSET_DATETIME_SPACE=/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;static IS_FULL_DATE=/(\d{4})-(\d+)-(\d+)/;static IS_FULL_TIME=/(\d+):(\d+)(?::(\d+))?/;static createDateWithOriginalFormat(t,n){if(e.IS_DATE_ONLY.test(n)){if(t.getUTCHours()!==0||t.getUTCMinutes()!==0||t.getUTCSeconds()!==0||t.getUTCMilliseconds()!==0){if(t instanceof ye)return t;let e=t.toISOString().replace(`Z`,``);return e=e.replace(/\.000$/,``),new ve(e,!1)}let e=t.toISOString().split(`T`)[0];return new ge(e)}if(e.IS_TIME_ONLY.test(n)){if(t instanceof _e)return t;{let e=N(t),r=pe(e.ms,n);return new _e(`${e.hours}:${e.minutes}:${e.seconds}${r}`,n)}}if(e.IS_LOCAL_DATETIME_T.test(n)){let e=N(t),r=pe(e.ms,n);return new ve(`${e.year}-${e.month}-${e.day}T${e.hours}:${e.minutes}:${e.seconds}${r}`,!1,n)}if(e.IS_LOCAL_DATETIME_SPACE.test(n)){let e=N(t),r=pe(e.ms,n);return new ve(`${e.year}-${e.month}-${e.day} ${e.hours}:${e.minutes}:${e.seconds}${r}`,!0,n)}if(e.IS_OFFSET_DATETIME_T.test(n)||e.IS_OFFSET_DATETIME_SPACE.test(n)){let r=n.match(/([+-]\d{2}:\d{2}|[Zz])$/),i=r?r[1]===`z`?`Z`:r[1]:`Z`,a=e.IS_OFFSET_DATETIME_SPACE.test(n),o=N(new Date(t.getTime()+me(i)*6e4)),s=a?` `:`T`,c=pe(o.ms,n,/\.(\d+)(?:[Zz]|[+-]\d{2}:\d{2})\s*$/);return new ye(`${o.year}-${o.month}-${o.day}${s}${o.hours}:${o.minutes}:${o.seconds}${c}${i}`,a)}return t}},ge=class extends Date{constructor(e){super(e)}toISOString(){let e=N(this);return`${e.year}-${e.month}-${e.day}`}},_e=class extends Date{originalFormat;constructor(e,t){let n=e;/:\d{2}:\d{2}/.test(e)||(n=e+`:00`),super(`0000-01-01T${n}Z`),this.originalFormat=t}toISOString(){let e=N(this);return`${e.hours}:${e.minutes}:${e.seconds}${pe(e.ms,this.originalFormat)}`}},ve=class extends Date{useSpaceSeparator=!1;originalFormat;constructor(e,t=!1,n){let r=e;/\d{2}:\d{2}:\d{2}/.test(e)||(r=e.replace(/(\d{2}:\d{2})([\s\-+TZ]|$)/,`$1:00$2`)),super(r.replace(` `,`T`)+`Z`),this.useSpaceSeparator=t,this.originalFormat=n||e}toISOString(){let e=N(this),t=this.useSpaceSeparator?` `:`T`;return`${e.year}-${e.month}-${e.day}${t}${e.hours}:${e.minutes}:${e.seconds}${pe(e.ms,this.originalFormat)}`}},ye=class extends Date{useSpaceSeparator=!1;originalOffset;originalFormat;constructor(e,t=!1){let n=e;/\d{2}:\d{2}:\d{2}/.test(e)||(n=e.replace(/(\d{2}:\d{2})([\s\-+TZ]|$)/,`$1:00$2`)),super(n.replace(` `,`T`)),this.useSpaceSeparator=t,this.originalFormat=e;let r=e.match(/([+-]\d{2}:\d{2}|[Zz])$/);r&&(this.originalOffset=r[1]===`z`?`Z`:r[1])}toISOString(){if(this.originalOffset){let e=N(new Date(this.getTime()+me(this.originalOffset)*6e4)),t=this.useSpaceSeparator?` `:`T`,n=pe(e.ms,this.originalFormat,/\.(\d+)(?:[Zz]|[+-]\d{2}:\d{2})\s*$/);return`${e.year}-${e.month}-${e.day}${t}${e.hours}:${e.minutes}:${e.seconds}${n}${this.originalOffset}`}let e=super.toISOString();return this.useSpaceSeparator?e.replace(`T`,` `):e}};const be=he,xe=`true`,Se=/e/i,Ce=/_/g,we=/^[+-]?inf$/,Te=/^[+-]?nan$/,Ee=/^[+-]?0x/i;function De(e){return e>=65&&e<=90||e>=97&&e<=122||e>=48&&e<=57||e===95||e===45}const Oe=/^[+-]?0o/i,ke=/^[+-]?0b/i;function Ae(e,t,n){for(let r=0;r23)throw new D(i,a,`"${r}": hour must be 00-23`)}if(t!==void 0){let e=parseInt(t,10);if(e<0||e>59)throw new D(i,a,`"${r}": minute must be 00-59`)}if(n!==void 0){let e=parseInt(n,10);if(e<0||e>60)throw new D(i,a,`"${r}": second must be 00-60`)}}function Re(e,t,n){if(Ee.test(e)||Oe.test(e)||ke.test(e))return;let r=e.replace(/^[+-]/,``).replace(/_/g,``);if(!/^[0-9]+$/.test(r))throw new D(t,n,`Invalid integer "${e}"`)}function ze(e,t,n,r,i,a,o){let s=r[0]+r[1].toUpperCase();if(RegExp(`^[+\\-]?`+s.replace(/([[\]])/g,`\\$1`)).test(e))throw new D(t,n,`${a} prefix must be lowercase "${r}"`);if(RegExp(`^[+\\-]?`+r+`_`,`i`).test(e))throw new D(t,n,`Underscore must be between digits`);let c=RegExp(`^[+\\-]?`+r,`i`),l=e.replace(c,``);if(!l||l===`_`||l.startsWith(`_`))throw new D(t,n,`Incomplete ${a.toLowerCase()} number`);let u=l.replace(/_/g,``);if(!i.test(u))throw new D(t,n,o??`Invalid ${a.toLowerCase()} digits`);if(/^[+-]/.test(e))throw new D(t,n,`${a} numbers cannot have a sign prefix`)}function*Be(e,t){let n=new se(te(e,t));try{for(;!n.next().done;){let t=$e(n,e);for(let e of t)yield e}}finally{y()}}function*Ve(e,t,n){for(let t of e)yield t;let r=new se(te(t));try{for(;!r.next().done;){let e=$e(r,t);for(let t of e)n&&Ue(t,n),yield t}}finally{y()}}function He(e,t){return{line:e.line+t.line-1,column:e.line===1?e.column+t.column:e.column}}function Ue(e,t){let n=[e];for(;n.length>0;){let e=n.pop();if(e.loc.start=He(e.loc.start,t),e.loc.end=He(e.loc.end,t),e.type===`KeyValue`){let t=e;n.push(t.value,t.key)}else if(e.type===`TableKey`||e.type===`TableArrayKey`||e.type===`InlineItem`)n.push(e.item);else if(e.type===`Table`||e.type===`TableArray`){let t=e;n.push(t.key);for(let e=t.items.length-1;e>=0;e--)n.push(t.items[e])}else if(e.type===`InlineTable`||e.type===`InlineArray`){let t=e.items;for(let e=t.length-1;e>=0;e--)n.push(t[e])}}}function We(e){return{type:`Comment`,loc:e.value.loc,raw:e.value.raw}}function Ge(e,t){let n=!e.peek().done&&e.peek().value.type===`Bracket`?`TableArray`:`Table`,r=n===`Table`;if(r&&e.value.raw!==`[`)throw new D(t,e.value.loc.start,`Expected table opening "[", found ${e.value.raw}`);if(!r){let n=e.peek();if(n.done)throw new D(t,e.value.loc.start,`Expected "[[" for Array of Tables, found end of input`);if(e.value.raw!==`[`||n.value.raw!==`[`)throw new D(t,e.value.loc.start,`Expected "[[", found ${e.value.raw+n.value.raw}`);let r=e.value,i=n.value;if(r.loc.end.line!==i.loc.start.line||r.loc.end.column!==i.loc.start.column)throw new D(t,r.loc.start,`"[[" brackets must be adjacent (no whitespace)`)}let i=r?{type:`TableKey`,loc:e.value.loc}:{type:`TableArrayKey`,loc:e.value.loc};if(e.next(),n===`TableArray`&&e.next(),e.done)throw new D(t,i.loc.start,`Expected table key, reached end of file`);if(e.value.type===`Bracket`&&e.value.raw===`]`)throw new D(t,e.value.loc.start,n===`TableArray`?`Array of Tables header [[]] requires a table name`:`Table header [] requires a table name`);let a=e.value.raw;je(a,t,e.value.loc.start),a.startsWith(`"`)||a.startsWith(`'`)||Ae(a,t,e.value.loc.start);let o;for(o=[Me(e.value.raw,t,e.value.loc.start)],i.item={type:`Key`,loc:e.value.loc,raw:e.value.raw,value:o};!e.peek().done&&e.peek().value.type===`Dot`;){e.next();let n=e.value;e.next();let r=e.value.raw;r.startsWith(`"`)||r.startsWith(`'`)||Ae(r,t,e.value.loc.start);let a=` `.repeat(n.loc.start.column-i.item.loc.end.column),o=` `.repeat(e.value.loc.start.column-n.loc.end.column);i.item.loc.end=e.value.loc.end,i.item.raw+=`${a}.${o}${e.value.raw}`;try{i.item.value.push(ie(e.value.raw))}catch(n){let r=n;throw new D(t,e.value.loc.start,r.message)}}if(e.next(),!e.done){let n=i.loc.start.line;if(e.value.loc.start.line!==n)throw new D(t,e.value.loc.start,r?`Table header must be single-line`:`Array of Tables header must be single-line`)}if(r&&(e.done||e.value.raw!==`]`))throw new D(t,e.done?i.item.loc.end:e.value.loc.start,`Expected table closing "]", found ${e.done?`end of file`:e.value.raw}`);if(!r&&(e.done||e.peek().done||e.value.raw!==`]`||e.peek().value.raw!==`]`))throw new D(t,e.done||e.peek().done?i.item.loc.end:e.value.loc.start,`Expected "]]" closing, found ${e.done||e.peek().done?`end of file`:e.value.raw+e.peek().value.raw}`);if(!r){let n=e.value,r=e.peek().value;if(n.loc.end.line!==r.loc.start.line||n.loc.end.column!==r.loc.start.column)throw new D(t,n.loc.start,`"]]" brackets must be adjacent (no whitespace)`)}if(r||e.next(),i.loc.end=e.value.loc.end,!e.peek().done){let n=e.peek().value;if(n.loc.start.line===i.loc.end.line&&n.type!==`Comment`)throw new D(t,n.loc.start,`Extra content after ${r?`table`:`Array of Tables`} header`)}let s=[];for(;!e.peek().done&&e.peek().value.type!==`Bracket`;){e.next();let n=$e(e,t);for(let e=0;e23)throw new D(t,n,`Offset "${e}": hour must be 00-23`);let c=parseInt(o,10);if(c<0||c>59)throw new D(t,n,`Offset "${e}": minute must be 00-59`)}if(!/^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)?$/.test(e)&&!/^\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/.test(e))throw/^\d{4}-\d{2}-\d{2}T$/.test(e)?new D(t,n,`"${e}": 'T' requires time component`):/^\d{4}-\d{2}-\d{2}[a-su-zA-SU-Z]/.test(e)?new D(t,n,`Invalid date "${e}": unexpected character after date`):/^\d{4}-\d{2}-\d{2}\d{2}:\d{2}/.test(e)?new D(t,n,`"${e}": missing 'T' or space separator`):new D(t,n,`Invalid datetime "${e}"`);let i=e.match(/^(\d+)-/);if(i&&i[1].length!==4)throw new D(t,n,`"${e}": year must be 4 digits`);let a=e.match(/^(\d+)-(\d+)-(\d+)/);if(a){let[,,r,i]=a;if(r.length!==2)throw new D(t,n,`"${e}": month must be 2 digits`);if(i.length!==2)throw new D(t,n,`"${e}": day must be 2 digits`)}let o=e.match(/[T ](\d+):(\d+)(?::(\d+))?/);if(o){let[,r,i,a]=o;Ie(r,i,a,e,t,n)}let s=e.match(/^(\d+):(\d+)(?::(\d+))?/);if(s&&!a){let[,r,i,a]=s;Ie(r,i,a,e,t,n)}let c=e.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/),l=e.match(/^(\d{2}):(\d{2})(?::(\d{2}))?/);if(c){let[,r,i,a,o,s,l]=c,u=parseInt(i,10);if(u<1||u>12)throw new D(t,n,`"${e}": month must be 01-12`);let d=parseInt(a,10);if(d<1||d>31)throw new D(t,n,`"${e}": day must be 01-31`);if(d>Ye(parseInt(r,10),u))throw new D(t,n,`"${e}": day ${a} invalid for ${r}-${i}`);Le(o,s,l,e,t,n)}else if(l){let[,r,i,a]=l;Le(r,i,a,e,t,n)}}function Ze(e,t){let n=e.value.loc,r=e.value.raw,i;if(we.test(r))i=r.startsWith(`-`)?-1/0:1/0;else if(Te.test(r)){let e=new Float64Array(1),t=new DataView(e.buffer);r.startsWith(`-`)?(t.setUint32(0,0,!0),t.setUint32(4,4294443008,!0)):(t.setUint32(0,0,!0),t.setUint32(4,2146959360,!0)),i=e[0];let a=r.startsWith(`+`)?`+`:r.startsWith(`-`)?`-`:void 0;return{type:`Float`,loc:n,raw:r,value:i,nanSign:a}}else if(!e.peek().done&&e.peek().value.type===`Dot`){let a=n.start;{if(Se.test(r)&&!Ee.test(r))throw new D(t,n.start,`Float "${r}": dot after exponent`);let e=r;Pe(e,t,n.start,`the integer part of a float`);let i=e.replace(/^[+-]/,``);if(i===``||i===`_`)throw new D(t,n.start,`Float: digit required before dot`);Ne(e,t,n.start)}if(e.next(),e.peek().done||e.peek().value.type!==`Literal`)throw new D(t,e.value.loc.end,`Expected fraction value for Float`);e.next(),r+=`.${e.value.raw}`,n={start:a,end:e.value.loc.end};{let n=e.value.raw;if(!/^\d/.test(n))throw new D(t,e.value.loc.start,`Float: fraction must start with digit, found "${n}"`);Ne(n,t,e.value.loc.start,!1),Fe(n,r,t,e.value.loc.start)}i=Number(r.replace(Ce,``))}else{if(Ne(r,t,n.start),Fe(r,r,t,n.start),!e.peek().done&&e.peek().value.type===`Dot`)throw new D(t,e.peek().value.loc.start,`Float "${r}.": dot after exponent`);Pe(r,t,n.start,`the integer part of a float`),i=Number(r.replace(Ce,``))}if(Number.isNaN(i)&&!Te.test(r))throw new D(t,n.start,`Invalid float "${r}"`);return{type:`Float`,loc:n,raw:r,value:i}}function Qe(e,t){let n=e.value.raw,r=e.value.loc;if(/^\d{1,}-\d{1,}/.test(n)||/^\d{1,}:\d{1,}/.test(n)||/^\d{6}-\d{2}$/.test(n))throw new D(t,r.start,`Invalid integer "${n}"`);if(n===`-0`||n===`+0`)return{type:`Integer`,loc:r,raw:n,value:0};if(/^[+-]{2,}/.test(n))throw new D(t,r.start,`Double sign is not allowed in integers`);Pe(n,t,r.start,`decimal integers`),Ne(n,t,r.start),Re(n,t,r.start);let i=10;Ee.test(n)?(i=16,ze(n,t,r.start,`0x`,/^[0-9a-fA-F]+$/,`Hexadecimal`)):Oe.test(n)?(i=8,ze(n,t,r.start,`0o`,/^[0-7]+$/,`Octal`,`Invalid octal digits (must be 0-7)`)):ke.test(n)&&(i=2,ze(n,t,r.start,`0b`,/^[01]+$/,`Binary`,`Invalid binary digits (must be 0 or 1)`));let a=parseInt(n.replace(Ce,``).replace(Oe,``).replace(ke,``),i);if(Number.isNaN(a))throw new D(t,r.start,`Invalid integer "${n}"`);return{type:`Integer`,loc:r,raw:n,value:a}}function $e(e,t){if(e.value.type===`Comment`)return[We(e)];if(e.value.type===`Bracket`)return[Ge(e,t)];if(e.value.type===`Literal`)return et(e,t);throw e.value.type===`Equal`?new D(t,e.value.loc.start,`Missing key before '='`):new D(t,e.value.loc.start,`Unexpected token "${e.value.type}"`)}function et(e,t){let n=e.value.raw;if(n.endsWith(`:`))throw new D(t,{line:e.value.loc.start.line,column:e.value.loc.start.column+[...n].length-1},`Use '=' to separate keys and values, not ':'`);je(n,t,e.value.loc.start),n.startsWith(`"`)||n.startsWith(`'`)||Ae(n,t,e.value.loc.start);let r;r=[Me(e.value.raw,t,e.value.loc.start)];let i={type:`Key`,loc:e.value.loc,raw:e.value.raw,value:r};for(;!e.peek().done&&e.peek().value.type===`Dot`;){e.next();let n=e.value;e.next();let r=e.value.raw;je(r,t,e.value.loc.start),r.startsWith(`"`)||r.startsWith(`'`)||Ae(r,t,e.value.loc.start);let a=S(t,i.loc.end),o=S(t,n.loc.end),s=a.slice(i.loc.end.column,n.loc.start.column),c=o.slice(n.loc.end.column,e.value.loc.start.column);i.loc.end=e.value.loc.end,i.raw+=`${s}.${c}${e.value.raw}`,i.value.push(Me(e.value.raw,t,e.value.loc.start))}if(e.next(),!e.done&&e.value.loc.start.line!==i.loc.end.line)throw new D(t,e.value.loc.start,`"=" must be on same line as key`);if(e.done||e.value.type!==`Equal`)throw!e.done&&e.value.raw===`:`?new D(t,e.value.loc.start,`Use '=' to separate keys and values, not ':'`):new D(t,e.done?i.loc.end:e.value.loc.start,`Expected "=" for key-value`);let a=e.value.loc.start.column,o=e.value.loc.start.line;if(e.next(),e.done)throw new D(t,i.loc.start,`Expected value, reached EOF`);if(e.value.loc.start.line!==o)throw new D(t,e.value.loc.start,`Value must be on same line as '='`);if(e.done)throw new D(t,i.loc.start,`Expected value for key-value`);let s=tt(e,t),c=s[0];if(!e.peek().done){let n=e.peek().value;if(n.type===`Dot`&&n.loc.start.line===c.loc.end.line&&(c.type===`Float`||c.type===`Integer`))throw new D(t,n.loc.start,`Invalid number: multiple decimal points not allowed`);if((n.type===`Literal`||n.type===`Bracket`)&&n.loc.start.line===c.loc.end.line)throw new D(t,n.loc.start,`Key/value pairs must be separated by a newline`)}return s[0]={type:`KeyValue`,key:i,value:c,loc:{start:w(i.loc.start),end:w(c.loc.end)},equals:a},s}function tt(e,t){if(e.value.type===`Literal`){let n=e.value.raw;return n[0]===`"`||n[0]===`'`?[Ke(e,t)]:n===xe||n===`false`?[qe(e)]:/^\d/.test(n)&&(/^\d{1,}-\d{1,}/.test(n)||/^\d{1,}:\d{1,}/.test(n))?[Je(e,t)]:!e.peek().done&&e.peek().value.type===`Dot`||we.test(n)||Te.test(n)||Se.test(n)&&!Ee.test(n)?[Ze(e,t)]:[Qe(e,t)]}if(e.value.type===`Curly`){let[n,r]=rt(e,t);return[n,...r]}if(e.value.type===`Bracket`){let[n,r]=it(e,t);return[n,...r]}throw e.value.type===`Dot`?new D(t,e.value.loc.start,`Number cannot start with a dot`):new D(t,e.value.loc.start,`Unrecognized token type`)}function nt(e){for(let t=1;t=1)return e}}}function rt(e,t){if(e.value.raw!==`{`)throw new D(t,e.value.loc.start,`Expected "{" for inline table`);let n={type:`InlineTable`,loc:e.value.loc,items:[]},r=[];for(e.next();!e.done&&(e.value.type!==`Curly`||e.value.raw!==`}`);){if(e.value.type===`Comment`){r.push(We(e)),e.next();continue}if(e.value.type===`Comma`){let r=n.items[n.items.length-1];if(!r)throw new D(t,e.value.loc.start,`Leading comma in inline table`);if(r.comma)throw new D(t,e.value.loc.start,`Consecutive commas in inline table`);r.comma=!0,r.loc.end=e.value.loc.start,e.next();continue}let i=n.items[n.items.length-1];if(i&&!i.comma)throw new D(t,e.value.loc.start,`Missing comma between inline table items`);let a=$e(e,t),o=a[0];if(o.type===`KeyValue`){n.items.push({type:`InlineItem`,loc:T(o.loc),item:o,comma:!1});for(let e=1;e=-(2**53-1)&&e<=2**53-1}function ct(e){return typeof e==`bigint`}function lt(e){return typeof e==`number`&&(!st(e)||!isFinite(e)||Object.is(e,-0))}function ut(e){return typeof e==`boolean`}function dt(e){return Object.prototype.toString.call(e)===`[object Date]`}const ft=new Set([`Temporal.PlainDate`,`Temporal.PlainTime`,`Temporal.PlainDateTime`,`Temporal.ZonedDateTime`,`PlainDate`,`PlainTime`,`PlainDateTime`,`ZonedDateTime`]);function pt(e){var t;return typeof e==`object`&&!!e&&ft.has((t=e.constructor)==null?void 0:t.name)&&typeof e.equals==`function`}function mt(e){var t;let n=((t=e.constructor)==null?void 0:t.name)??``;if(n===`Temporal.ZonedDateTime`||n===`ZonedDateTime`){let t=e.toString(),n=t.match(/\[(.+)\]$/);if(n&&!/^[+-]\d{2}:\d{2}$/.test(n[1]))throw Error(`ZonedDateTime with IANA timezone "${t}" cannot be represented in TOML. TOML only supports offset-based timezones (+05:30, Z).`);return t.replace(/\[.*\]$/,``).replace(/(\+00:00)$/,`Z`)}let r=e.toString();if(/\[.*\]/.test(r))throw Error(`Temporal value contains unsupported annotation: "${r}". TOML only supports ISO 8601 calendar.`);return r}function F(e){return e&&typeof e==`object`&&!dt(e)&&!pt(e)&&!Array.isArray(e)}function ht(e){return e!=null&&typeof e[Symbol.iterator]==`function`}function gt(e){return e.startsWith(`"`)&&!e.startsWith(`"""`)}function _t(e){return e.startsWith(`"""`)}function vt(e){return e.startsWith(`'`)&&!e.startsWith(`'''`)}function yt(e){return e.startsWith(`'''`)}function bt(e,t){return t in e}function I(e,t){if(e.length!==t.length)return!1;for(let n=0;n0;){let e=r.pop();if(`text`in e){t.push(e.text),`leave`in e&&n.delete(e.leave);continue}let i=e.value;if(F(i)){if(n.has(i))throw TypeError(`Cannot stableStringify a circular structure`);n.add(i);let e=Object.keys(i).sort();t.push(`{`),r.push({leave:i,text:`}`});for(let t=e.length-1;t>=0;t--){let n=e[t];t=0;e--)e57343)){if(n<=56319){let n=e.charCodeAt(t+1);if(n>=56320&&n<=57343){t++;continue}}return{index:t,code:n}}}}function Et(e,t){if(wt!==void 0&&wt.call(e))return;let n=Tt(e);if(!n)return;let r=n.code.toString(16).toUpperCase().padStart(4,`0`);throw Error(`${t} contains a lone surrogate (U+${r}) at index ${n.index}. Unpaired surrogates are not Unicode scalar values and cannot be encoded as TOML.`)}function Dt(e,t){ht(e)?n(e,null):r(e,null);function n(e,t){for(let n of e)r(n,t)}function r(e,i){let a=t[e.type];switch(a&&typeof a==`function`&&a(e,i),a&&a.enter&&a.enter(e,i),e.type){case`Document`:n(e.items,e);break;case`Table`:r(e.key,e),n(e.items,e);break;case`TableKey`:r(e.item,e);break;case`TableArray`:r(e.key,e),n(e.items,e);break;case`TableArrayKey`:r(e.item,e);break;case`KeyValue`:r(e.key,e),r(e.value,e);break;case`InlineArray`:n(e.items,e);break;case`InlineItem`:r(e.item,e);break;case`InlineTable`:n(e.items,e);break;case`Key`:case`String`:case`Integer`:case`Float`:case`Boolean`:case`DateTime`:case`Comment`:break;default:throw Error(`Unrecognized node type "${e.type}"`)}a&&a.exit&&a.exit(e,i)}}function Ot(e){return new TextDecoder(`utf-8`,{fatal:!0,ignoreBOM:!0}).decode(e)}function kt(e){return e.charCodeAt(0)===65279}function At(e){return kt(e)?e.slice(1):e}const jt=`auto`,Mt=`auto`;function Nt(e){for(let t of e){let e=Lt(t);if(e!==null)return e}return!1}function Pt(e,t){for(let n of t){let t=Ft(n,e);if(t!==null)return t}return!0}function Ft(e,t){if(!e||typeof e!=`object`)return null;if((e.type===`InlineArray`||e.type===`InlineTable`)&&e.loc){let n=It(e.loc,t);if(n!==null)return n}if(e.items&&Array.isArray(e.items))for(let n of e.items){let e=Ft(n,t);if(e!==null)return e;if(n.item){let e=Ft(n.item,t);if(e!==null)return e}}for(let n of[`value`,`key`,`item`])if(e[n]){let r=Ft(e[n],t);if(r!==null)return r}return null}function It(e,t){if(!e||!e.start||!e.end)return null;let n=t.split(/\r?\n/),r=e.start.line-1,i=e.end.line-1,a=e.start.column,o=e.end.column,s=``;if(r===i){var c;s=((c=n[r])==null?void 0:c.substring(a,o+1))||``}else{n[r]&&(s+=n[r].substring(a));for(let e=r+1;e0:u?u[1].length>0:null}function Lt(e){if(!e||typeof e!=`object`)return null;if(e.type===`InlineArray`&&e.items&&Array.isArray(e.items)||e.type===`InlineTable`&&e.items&&Array.isArray(e.items))return Rt(e.items);if(e.type===`KeyValue`&&e.value)return Lt(e.value);if(e.items&&Array.isArray(e.items))for(let t of e.items){let e=Lt(t);if(e!==null)return e}return null}function Rt(e){if(e.length===0)return null;let t=e[e.length-1];return t&&typeof t==`object`&&`comma`in t?t.comma===!0:!1}function zt(e){let t=e.indexOf(` +`);return t>0&&e[t-1]===`\r`?`\r +`:` +`}function Bt(e){let t=0,n=e.length;for(;n>0&&e[n-1]===` +`;)t++,n--,n>0&&e[n-1]===`\r`&&n--;return t}function Vt(e){let t=e.split(/\r?\n/),n=0,r=0;for(let e of t)if(e.length!==0&&(e[0]===` `?n++:e[0]===` `&&r++,n+r>=5))break;return n>0&&n>=r}function Ht(e){let t=0;for(;t{var s,c,l;if(!e||typeof e!=`object`)return;if(t!==void 0&&a.has(e.type)&&((s=e.loc)==null||(s=s.start)==null?void 0:s.line)>t){let t=Ht(n[e.loc.start.line-1]??``);t>0&&r.push(t)}let u=i.has(e.type)&&((c=e.loc)==null||(c=c.end)==null?void 0:c.line)>((l=e.loc)==null||(l=l.start)==null?void 0:l.line)?e.loc.start.line:t;if(Array.isArray(e.items))for(let t of e.items)o(t,u);e.item&&o(e.item,u),e.value&&o(e.value,u)},s=Array.isArray(t)?t:Array.from(t??[]);for(let e of s)o(e);if(r.length>0)return Math.min(...r);let c=n.filter(e=>e.trim().length>0&&!e.trimStart().startsWith(`#`)).map(Ht).filter(e=>e>0);return c.length>0?Math.min(...c):2}const Wt=new Map([[` +`,` +`],[`\r +`,`\r +`],[`\\n`,` +`],[`\\r\\n`,`\r +`],[`LF`,` +`],[`CRLF`,`\r +`],[`UNIX`,` +`],[`DOS`,`\r +`]]);function Gt(e){let t=Wt.get(e)??Wt.get(e.toUpperCase());if(t===void 0)throw TypeError(`Invalid newLine value: expected LF or CRLF`);return t}function Kt(e){if(!e||typeof e!=`object`)return{};let t=e=>typeof e==`boolean`?null:`expected boolean, got ${typeof e}`,n=e=>e==null||typeof e==`boolean`||e===`auto`||e===`parent`||typeof e==`number`&&Number.isInteger(e)&&e>=0?null:`expected boolean, non-negative integer, "auto" or "parent", got ${typeof e}`,r={newLine:e=>typeof e==`string`?null:`expected string, got ${typeof e}`,trailingNewline:e=>typeof e==`boolean`||typeof e==`number`?null:`expected boolean or number, got ${typeof e}`,trailingComma:t,bracketSpacing:t,leadingBom:t,inlineTableStart:e=>e==null||typeof e==`number`&&Number.isInteger(e)&&e>=0?null:`expected non-negative integer or undefined, got ${typeof e}`,truncateZeroTimeInDates:t,useTabsForIndentation:t,indentWidth:e=>e==null||typeof e==`number`&&Number.isInteger(e)&&e>0?null:`expected positive integer or undefined, got ${typeof e}`,minimumDecimals:e=>e==null||typeof e==`number`&&Number.isInteger(e)&&e>=0?null:`expected non-negative integer or undefined, got ${typeof e}`,updateOrder:t,multilineTable:n,multilineArray:n},i={},a=[],o=[];for(let t in e){let n=Object.prototype.hasOwnProperty.call(r,t)?r[t]:void 0;if(n){let r=e[t],a=n(r);a?o.push(`${t} (${a})`):i[t]=r}else Object.prototype.hasOwnProperty.call(e,t)&&a.push(t)}if(a.length>0&&console.warn(`toml-patch: Ignoring unsupported format properties: ${a.join(`, `)}. Supported properties are: ${Object.keys(r).join(`, `)}`),o.length>0)throw TypeError(`Invalid types for format properties: ${o.join(`, `)}`);return`newLine`in i&&(i.newLine=Gt(i.newLine)),i}function L(e,t){if(e){if(e instanceof R)return e;{let n=Kt(e);return new R(n.newLine??t.newLine,n.trailingNewline??t.trailingNewline,n.trailingComma??t.trailingComma,n.bracketSpacing??t.bracketSpacing,`inlineTableStart`in n?n.inlineTableStart:t.inlineTableStart,n.truncateZeroTimeInDates??t.truncateZeroTimeInDates,n.useTabsForIndentation??t.useTabsForIndentation,n.minimumDecimals??t.minimumDecimals,n.leadingBom??t.leadingBom,n.updateOrder??t.updateOrder,n.indentWidth??t.indentWidth,n.multilineTable??t.multilineTable,n.multilineArray??t.multilineArray)}}return t}var R=class e{newLine;trailingNewline;trailingComma;bracketSpacing;leadingBom;inlineTableStart;truncateZeroTimeInDates;useTabsForIndentation;indentWidth;minimumDecimals;updateOrder;multilineTable;multilineArray;constructor(e,t,n,r,i,a,o,s,c,l,u,d,f){this.newLine=e==null?` +`:Gt(e),this.trailingNewline=t??1,this.trailingComma=n??!1,this.bracketSpacing=r??!0,this.inlineTableStart=i??1,this.truncateZeroTimeInDates=a??!1,this.useTabsForIndentation=o??!1,this.indentWidth=this.useTabsForIndentation?1:u??2,this.minimumDecimals=s??0,this.leadingBom=c??!1,this.updateOrder=l??!1,this.multilineTable=d??`auto`,this.multilineArray=f??`auto`}static default(){return new e(` +`,1,!1,!0,1,!1,!1,0,!1,!1,2,jt,Mt)}static autoDetectFormat(t){return e.autoDetectFormatWithCst(t)}static autoDetectFormatWithCst(t,n){let r=e.default();r.leadingBom=kt(t);let i=At(t),a=[];r.newLine=zt(i),r.trailingNewline=Bt(i);try{a=Array.isArray(n)?n:Array.from(n??Be(i)),r.trailingComma=Nt(a),r.bracketSpacing=Pt(i,a)}catch{r.trailingComma=!1,r.bracketSpacing=!0}return r.useTabsForIndentation=Vt(i),r.indentWidth=r.useTabsForIndentation?1:Ut(i,a),r.inlineTableStart=1,r.truncateZeroTimeInDates=!1,r.minimumDecimals=0,r.updateOrder=!1,r.multilineTable=jt,r.multilineArray=Mt,r}static autoDetectFormatWithAst(t,n){return e.autoDetectFormatWithCst(t,n)}};const qt=new WeakMap,Jt=new WeakMap,Yt=new WeakSet,Xt=new WeakMap;function Zt(e,t){return(e[t.line-1]??e[e.length-1])+t.column}function Qt(e,t){let n=$t(t);for(let t of e)n(t)}function $t(e){let n=[0];for(let t=0;t{let o=[i];for(;o.length>0;){let i=o.pop(),s=[Zt(n,i.loc.start),Zt(n,i.loc.end)];if(Object.defineProperty(i,"range",{configurable:!0,enumerable:!1,value:s}),qt.set(i,e),Xt.set(i,tn(i).slice()),a(i))o.push(i.value),o.push(i.key);else if(u(i))o.push(i.item);else if(t(i)||r(i)){o.push(i.key);let e=i.items;for(let t=e.length-1;t>=0;t--)o.push(e[t])}else if(p(i)){let e=i.items;for(let t=e.length-1;t>=0;t--)o.push(e[t])}}}}function z(e){return qt.get(e)}function en(e){let t=Xt.get(e);if(!t)return!1;let n=tn(e);return t.length===n.length&&t.every((e,t)=>e===n[t])}function tn(e){return a(e)?[e.key,e.value]:u(e)?[e.item]:t(e)||r(e)?[e.key,...e.items]:p(e)?e.items:[]}function nn(e){if(Yt.has(e))return;let t=[e];for(;t.length>0;){let e=t.pop();for(let n of tn(e))Jt.set(n,e),t.push(n)}Yt.add(e)}function rn(e){let t=[e];for(;t.length>0;){let e=t.pop();on(e);for(let n of tn(e))t.push(n)}}function an(e){rn(e)}function on(e){Object.defineProperty(e,"dirty",{configurable:!0,enumerable:!1,value:!0,writable:!0})}function sn(e,t,n){nn(e),n&&(Jt.set(n,t),rn(n));let r=t;for(;r;)on(r),r=Jt.get(r)}function cn(e){let t=Xt.get(e);return!!(t!=null&&t[0])&&t[0].loc.start.line>e.loc.start.line}const ln=new WeakMap,un=new WeakSet,dn=new WeakSet,fn=new WeakSet,pn=new WeakSet;function mn(e,t,n,r){let i=e===`array`?r.multilineArray:r.multilineTable;return typeof i==`boolean`?i:typeof i==`number`?t>=i:i===`parent`&&n}function hn(e,t){ln.set(e,t),t&&e.items.length===0&&(e.loc.end.line=e.loc.start.line+1,e.loc.end.column=e.loc.start.column+1)}function gn(e){return ln.get(e)}function _n(e){un.add(e)}function vn(e){return un.has(e)}function yn(e){dn.add(e)}function bn(e){return dn.has(e)}function xn(e){fn.add(e)}function Sn(e){return fn.has(e)}function Cn(e){pn.add(e)}function wn(e){return pn.has(e)}function Tn(e){return e.items.length?e.items.some((t,n)=>{let r=e.items[n-1];return t.loc.start.line>((r==null?void 0:r.loc.end.line)??e.loc.start.line)}):e.loc.end.line>e.loc.start.line}function En(e,t){let n=(e,r)=>{if(e===t)return r;if(a(e))return n(e.value,r);if(u(e))return n(e.item,r);if(l(e)||d(e)){for(let t of e.items){let e=n(t,r+1);if(e!==void 0)return e}return}if(p(e))for(let t of e.items){let e=n(t,r);if(e!==void 0)return e}};return n(e,0)}function Dn(e,t){let n=(e,r)=>{if(e===t)return r;if(a(e))return n(e.value,r);if(u(e))return n(e.item,r);if(l(e)||d(e)){for(let t of e.items){let r=n(t,e);if(r)return r}return}if(p(e))for(let t of e.items){let e=n(t,r);if(e)return e}};return n(e,void 0)}const On=new WeakSet,kn=new WeakSet,An=new WeakMap,jn=new WeakMap;function Mn(e,t){An.set(e,t)}function Nn(e,t){jn.set(e,t)}function Pn(e){kn.add(e)}const Fn=new WeakMap,In=new WeakSet,Ln=new WeakSet;function Rn(e){return Ln.has(e)}function zn(e){Ln.delete(e)}function Bn(e,t,n){rr(n,B(e),t)}function Vn(e){an(e),On.add(e)}const Hn=new WeakMap,Un=e=>(Hn.has(e)||Hn.set(e,new WeakMap),Hn.get(e));function Wn(e){return Hn.has(e)||Hn.set(e,new WeakMap),Hn.get(e)}const Gn=new WeakMap;function B(e){return Gn.has(e)||Gn.set(e,new WeakMap),Gn.get(e)}function Kn(e,t,n,r){if(p(t)){let e=t.items.indexOf(n);if(e<0)throw Error(`Item not found in parent for replace`);t.items.splice(e,1,r)}else if(a(t)&&d(t.value)&&!d(n)){let e=t.value.items.indexOf(n);if(e<0)throw Error(`Item not found in parent for replace`);t.value.items.splice(e,1,r)}else if(m(t))t.item=r;else if(a(t))t.key===n?t.key=r:t.value=r;else throw Error(`Unsupported parent type "${t.type}" for replace`);W(r,{lines:n.loc.start.line-r.loc.start.line,columns:n.loc.start.column-r.loc.start.column});let i=b(n.loc),o=b(r.loc);rr({lines:o.lines-i.lines,columns:o.columns-i.columns},B(e),r,n),kn.has(e)||sn(e,t,r),On.add(e)}function V(t,n,r,i,a,o,s,c=!1){if(!p(n))throw Error(`Unsupported parent type "${n.type}" for insert`);i=i!=null&&typeof i==`number`?i:n.items.length;let u,m;l(n)||d(n)?{shift:u,offset:m}=Zn(n,r,i,An.get(t)??2):a&&e(n)?{shift:u,offset:m}=Qn(n,r,i):{shift:u,offset:m}=Jn(t,n,r,i,s),W(r,u,{first_line_only:c});let h=n.items[i-1],g=h&&B(t).get(h);if(g&&(m.lines+=g.lines,m.columns+=g.columns,B(t).delete(h)),(d(n)||l(n))&&m.lines!==0&&(o||p(t))&&t!==n){let e=r.loc.start.line,i=o??t.items,a=qn(n,i);for(let t=0;ta.loc.end.line||r{t===e&&(r=!0)};if(Dt(t,{InlineArray:i,InlineTable:i}),r)return t}return e}function Jn(n,i,a,o,s){if(!h(a))throw Error(`Incompatible child type "${a.type}"`);let c=i.items[o-1],l=e(i)&&!i.items.length,u=e(i)&&!l&&c===void 0;i.items.splice(o,0,a);let d;if(c!==void 0){if(kn.has(n))d=c;else{let e=!1;for(let t=0;te||a.line===e&&a.column>t)&&(e=a.line,t=a.column,d=r)}}}}let p=d?{line:d.loc.end.line,column:f(d)?i.loc.start.column:d.loc.start.column}:w(i.loc.start),m=t(a)||r(a),g=0;s===void 0?l||u||(g=m?2:1):g=s,p.line+=g;let _={lines:p.line-a.loc.start.line,columns:p.column-a.loc.start.column},v=b(a.loc),y=!kn.has(n)&&c===void 0&&(t(i)||r(i)||e(i))&&Fn.has(i),x=y&&!In.has(i);y&&(Fn.delete(i),In.delete(i));let S=y&&!x,C=0;if(S){let e=t(i)||r(i)?B(n).get(i.key):Un(n).get(i);C=e?-e.lines:1}let T=x||S?g-2:g-1,E={lines:u?v.lines+1:v.lines+T,columns:v.columns};return{shift:{lines:_.lines+C,columns:_.columns},offset:E}}function Yn(e,t,n,r={}){let{useNewLine:i=!1,skipCommaSpace:a=2,skipBracketSpace:o=1,hasCommaHandling:s=!1,isLastElement:c=!1,hasSeparatingCommaBefore:u=!1,hasSeparatingCommaAfter:p=!1,hasTrailingComma:m=!1,indentWidth:h=2}=r,g=n>0?e.items[n-1]:void 0,_=g?{line:g.loc.end.line,column:i?f(g)?e.loc.start.column:g.loc.start.column:g.loc.end.column}:w(e.loc.start);if(!g&&i){let t=e.items.find((e,t)=>t>n&&!f(e));t?_.column=t.loc.start.column:e.loc.end.line>e.loc.start.line&&(_.column=(l(e)||d(e)?jn.get(e):void 0)??e.loc.end.column-1+h)}let v=0;if(i)v=1;else{let e=u||!s&&!!g;e&&s?_.column+=a:(e||s&&!g)&&(_.column+=o)}_.line+=v;let y={lines:_.line-t.loc.start.line,columns:_.column-t.loc.start.column},x=b(t.loc);if(!s)return{shift:y,offset:{lines:x.lines+(v-1),columns:x.columns}};let S=u&&m&&!p&&c,C=0;S&&(C=-1);let T=x.columns+(u||p?a:0)+(m?1+C:0);return i&&c&&g&&e.loc.end.line===g.loc.end.line&&(T=x.columns-(g.loc.end.column-g.loc.start.column)),{shift:y,offset:{lines:x.lines+(v-1),columns:T}}}function Xn(e){return fe(e)??2}function Zn(e,t,n,r){if(!u(t))throw Error(`Incompatible child type "${t.type}"`);let i=Xn(e),a=n==null?P(e.items):e.items[n-1],o=n==null||n===e.items.length,s=n==null?void 0:e.items[n];e.items.splice(n,0,t);let c=!!a,l=!o;return c&&(a.comma=!0),l&&(t.comma=!0),Yn(e,t,n,{useNewLine:nr(e,t)&&!(a&&s&&s.loc.start.line===a.loc.end.line&&s.loc.start.column>=a.loc.end.column)&&!(gn(e)!==!0&&d(e)&&!s&&a&&a.loc.end.line===e.loc.end.line),skipCommaSpace:i,hasCommaHandling:!0,isLastElement:o,hasSeparatingCommaBefore:c,hasSeparatingCommaAfter:l,hasTrailingComma:o&&t.comma===!0,indentWidth:r})}function Qn(e,t,n){let r=Yn(e,t,n,{useNewLine:!1,hasCommaHandling:!1});return e.items.splice(n,0,t),r}function H(n,i,a,o){if(!p(i))throw Error(`Unsupported parent type "${i.type}" for remove`);kn.has(n)||sn(n,i);let s=i.items.indexOf(a);if(s<0){if(s=i.items.findIndex(e=>m(e)&&e.item===a),s<0)throw Error(`Node not found in parent for removal`);a=i.items[s]}let c=i.items[s-1],h=i.items[s+1];i.items.splice(s,1);let g=b(a.loc);h&&f(h)&&h.loc.start.line===a.loc.end.line&&(g=b({start:a.loc.start,end:h.loc.end}),h=i.items[s+1],i.items.splice(s,1));let _=c&&u(c)||h&&u(h),v=c?B(n).get(c):void 0,y=c&&c.loc.end.line===a.loc.start.line+((v==null?void 0:v.lines)??0),x=h&&h.loc.start.line===a.loc.end.line,S=_&&(y||x),C={lines:-(g.lines-+!!S),columns:S?-g.columns:0};if((e(i)||t(i)||r(i))&&h&&!S&&h.loc.start.line>a.loc.end.line){let e=t(a)||r(a),o=t(h)||r(h),l=-1;for(let e=0;el&&(l=t)}let u=c?B(n).get(c):void 0,d=B(n).get(a),f=c?e?a.loc.start.line+((u==null?void 0:u.lines)??0)-l-1:0:o?h.loc.start.line+((d==null?void 0:d.lines)??0)-a.loc.end.line-1:0;f>0&&(C.lines-=f)}let w=(d(i)||l(i))&&i.loc.end.line>i.loc.start.line,T=w&&a.loc.start.line===i.loc.start.line;w&&!T&&c===void 0&&h===void 0&&a.loc.start.line>i.loc.start.line&&i.loc.end.line>a.loc.end.line&&Cn(i),c===void 0&&h===void 0&&(!w||T)&&(C.lines=0,C.columns=0,(d(i)||l(i))&&Ln.add(i),(t(i)||r(i)||e(i))&&Fn.set(i,!0)),(c!==void 0||h!==void 0)&&(t(i)||r(i)||e(i))&&In.add(i);let E=l(i)||d(i)?Xn(i):2;if(_&&y&&(C.columns-=E),_&&!c&&h&&(nr(i)||(C.columns-=E)),_&&c&&!h&&(c.comma=!!a.comma,y)){var D;let e=c?((D=B(n).get(c))==null?void 0:D.columns)??0:0;C.columns=c.loc.end.column-a.loc.end.column-e}let O,k;c?(O=c,k=B(n)):(t(i)||r(i))&&`key`in i?(O=i.key,k=B(n)):(O=i,k=Un(n));let ee=B(n),A=k.get(O);A&&(C.lines+=A.lines,C.columns+=A.columns);let te=ee.get(a);if(te&&(C.lines+=te.lines,S&&(C.columns+=te.columns)),k.set(O,C),On.add(n),w&&(o||p(n))&&n!==i){let e=a.loc.start.line,t=o??n.items,r=qn(i,t),s=[];for(let n=0;nr.loc.end.line||(o===e&&(o=0;e--)t.splice(s[e],1)}}function $n(e){let t=e.loc.start.line,n=e.loc.start.column;if(`key`in e){let r=e.key.loc.end;(r.line>t||r.line===t&&r.column>n)&&(t=r.line,n=r.column)}for(let r=0;rt||i.line===t&&i.column>n)&&(t=i.line,n=i.column)}e.loc.end={line:t,column:n}}function er(e,t,n=!0){if(!n||!t.items.length)return;rr({lines:0,columns:1},Un(e),t);let r=P(t.items);rr({lines:0,columns:1},B(e),r),On.add(e)}function tr(e,t,n=!1){if(!n||!t.items.length)return;let r=P(t.items);r.comma=!0,rr({lines:0,columns:1},B(e),r),On.add(e)}function U(e){if(!On.has(e))return;let t=Un(e),n=B(e),r=0,i={};function a(e){switch(e.type){case`Document`:{let t=e;o(t);for(let e=0;e{if(!o||e.loc.start.line===c){e.loc.start.column+=i;let t=(!o||s)&&e.loc.end.line!==e.loc.start.line&&((l(e)||d(e))&&gn(e)!==void 0&&!vn(e)||u(e)&&((l(e.item)||d(e.item))&&gn(e.item)!==void 0&&!vn(e.item)||a(e.item)&&(l(e.item.value)||d(e.item.value))&&gn(e.item.value)!==void 0&&!vn(e.item.value))||a(e)&&(l(e.value)||d(e.value))&&gn(e.value)!==void 0);(e.loc.end.line===e.loc.start.line||t)&&(e.loc.end.column+=i)}e.loc.start.line+=r,e.loc.end.line+=r};return Dt(e,{Table:p,TableKey:p,TableArray:p,TableArrayKey:p,KeyValue(e){let t=!o||e.loc.start.line===c;p(e),t&&(e.equals+=i)},Key:p,String:p,Integer:p,Float:p,Boolean:p,DateTime:p,InlineArray:p,InlineItem:p,InlineTable:p,Comment:p}),e}function nr(e,t){let n=gn(e);if(n===!0)return!0;if(n!==void 0||!e.items.length)return!1;let r=t?e.items.filter(e=>e!==t):e.items;return r.length?e.loc.end.line>e.loc.start.line&&r.every((e,t)=>t===0||e.loc.start.line>r[t-1].loc.start.line):e.loc.end.line>e.loc.start.line}function rr(e,t,n,r){let i=t.get(r||n);i&&(e.lines+=i.lines,e.columns+=i.columns),t.set(n,e)}function ir(e){let t=e.match(/\r\n|\n/);return(t==null?void 0:t[0])===`\r +`?`\r +`:` +`}function ar(e,t){var n;let r=ir(e);e=e.replace(/\r\n/g,` +`).replace(/\n/g,r);let i,a;e.startsWith(`"""\\${r}`)?(i=4+r.length,a=`"""\\${r}`):e.startsWith(`"""${r}`)?(i=3+r.length,a=`"""${r}`):(i=3,a=`"""`);let o=e.length-3,s=e.slice(i,o).split(r),c=s[s.length-1],l=s.length>1&&/^[\t ]*$/.test(c),u=l?c:``,d=(l?s.slice(0,-1):s).map(e=>{var t;if(/^[\t ]*$/.test(e))return{indent:e,content:``,trailingWs:``,hasBackslash:!1,isBlank:!0};let n=e,r=0;for(let e=n.length-1;e>=0&&n[e]===`\\`;e--)r++;let i=r%2==1;i&&(n=n.slice(0,-1));let a=((t=n.match(/^[\t ]*/))==null?void 0:t[0])??``,o=n.slice(a.length),s=o.match(/([\t ]+)$/),c=s?s[1]:``;return{indent:a,content:o.slice(0,o.length-c.length),trailingWs:c,hasBackslash:i,isBlank:!1}}),f=d.filter(e=>!e.isBlank),p=Math.max(...f.map(e=>e.content.length+e.trailingWs.length),1),m=f.filter(e=>e.hasBackslash),h=m[m.length-1]??f[f.length-1]??{indent:``,trailingWs:``,hasBackslash:!1,content:``,isBlank:!1},g=f[f.length-1]??h,_=[];{let e=[],t=0;for(let n of d)n.isBlank?e.push(n.indent):(t>0&&_.push(e),e=[],t++)}let v=f.some((e,t)=>!e.hasBackslash&&t0?((n=t.match(/^[\t ]*/))==null?void 0:n[0])??``:``,x=e=>{let t=e.match(/\S+| +/g)??[],n=[],r=0;for(;r0&&n[0]===` `)return null;let i=e[e.length-1];if(i.length>0&&i.length-i.trimEnd().length!==g.trailingWs.length)return null;for(let t=0;t0&&E.set(o,i);for(let e of a)S.push(e);t0&&(e[0]===` `||e.length-e.trimEnd().length!==g.trailingWs.length))return null;if(!v){let t=0,n=0;for(let r=0;r=t;r--){let t=f[r],a=t.content+t.trailingWs;if(a.length===0)break;let s=e.length-o-a.length;if(s>=n&&e.slice(s,s+a.length)===a)i++,o+=a.length;else break}if(i>0){let s=e.slice(n,e.length-o);if(s.length===0||s[0]!==` `){for(let e=0;e0)for(let t of _[e])C.push(t)}if(s.length>0){let e=x(s),n=e.join(``),r=/(?0&&!r){let e=t-1;if(e<_.length&&_[e].length>0)for(let t of _[e])C.push(t)}for(let n=0;n0&&/\s$/.test(i)?``:s.endsWith(` `)?` `:``;if(C.push(`${c}${i}${l}\\`),n0)for(let t of _[e])C.push(t)}}let a=f.length-i-1;if(!r&&a>=0&&a<_.length&&_[a].length>0)for(let e of _[a])C.push(e)}else if(t>0){let e=t-1;if(e<_.length&&_[e].length>0)for(let t of _[e])C.push(t)}let e=f.length-i;for(let t=e;te){let e=t-1;if(e<_.length&&_[e].length>0)for(let t of _[e])C.push(t)}C.push(r?`${n.indent}${n.content}${n.trailingWs}${n.hasBackslash&&n.content!==``?`\\`:``}`:`${n.indent}${n.content}${n.trailingWs}\\`)}return l&&!(g.hasBackslash&&g.content===``)?`${a}${C.join(r)}${r}${u}"""`:`${a}${C.join(r)}"""`}}let s=e.slice(n);if(t>0&&(n===e.length||s.length>0&&s[0]!==` `)){for(let e=0;e0)for(let t of _[e])C.push(t)}if(n===e.length){let e=f[t-1],n=g.hasBackslash&&e.content!==``?`\\`:``;C[C.length-1]=`${e.indent}${e.content}${g.trailingWs}${n}`}else{let e=x(s),n=e.join(``),r=/(?0)for(let e of _[i])C.push(e);for(let n=0;n0&&/\s$/.test(a)?``:` `,u=`\\`),C.push(`${c}${a}${l}${u}`),!i){let e=t+n;if(!r&&e<_.length&&_[e].length>0)for(let t of _[e])C.push(t)}}}return l&&!(g.hasBackslash&&g.content===``)?`${a}${C.join(r)}${r}${u}"""`:`${a}${C.join(r)}"""`}}let n=x(e);for(let e of n)S.push(e)}for(let e=0;e0?E.get(e)??``:i?y&&e===0?b:i.indent:r?g.indent:h.indent,o=S[e],s,c;if(r&&t?(s=g.trailingWs,c=g.hasBackslash&&g.content!==``?`\\`:``):r?(s=g.trailingWs,c=``):(s=o.length>0&&/\s$/.test(o)?``:` `,c=`\\`),C.push(`${a}${o}${s}${c}`),!t&&!n&&!D){let t=S.join(``);if(!/(?0)for(let t of _[e])C.push(t)}}return l&&!(g.hasBackslash&&g.content===``)?`${a}${C.join(r)}${r}${u}"""`:`${a}${C.join(r)}"""`}function or(e){switch(e){case`b`:return`\b`;case`t`:return` `;case`n`:return` +`;case`f`:return`\f`;case`r`:return`\r`;case`"`:return`"`;case`\\`:return`\\`;default:return null}}function sr(e,t){return t===`singleline-basic`?e<=31||e===127:e>=0&&e<=7||e===11||e>=14&&e<=31||e===127}function cr(e,t){switch(e){case`\\`:return`\\\\`;case`\b`:return`\\b`;case` `:return`\\t`;case`\f`:return`\\f`;case`"`:return t===`singleline-basic`?`\\"`:`"`;case` +`:return t===`singleline-basic`?`\\n`:` +`;case`\r`:return t===`singleline-basic`?`\\r`:`\r`;default:{let n=e.charCodeAt(0);return sr(n,t)?`\\u${n.toString(16).padStart(4,`0`).toUpperCase()}`:e}}}function lr(e){let t=new Map;for(let n=0;n(Et(e,`Key ${JSON.stringify(e)}`),k.test(e)?e:_r(e))).join(`.`)}function yr(e){let t=vr(e);return{type:`Key`,loc:{start:E(),end:{line:1,column:t.length}},raw:t,value:e}}function br(e,t){return Et(e,`String value`),t?xr(e,t):Sr(e)}function xr(e,t){if(gt(t))return Sr(e,t);if(vt(t))return e.includes(`'`)?e.includes(`'''`)?Sr(e):Tr(e,`'''${t.slice(1,-1)}'''`):Cr(e);if(yt(t))return e.includes(`'''`)?wr(e,`"""${t.slice(3,-3)}"""`):Tr(e,t);if(_t(t))return wr(e,t);throw Error(`Existing raw string value is not valid: ${t}`)}function Sr(e,t){let n=``;return n=t?`"${dr(e,t,`singleline-basic`)}"`:_r(e),{type:`String`,loc:{start:E(),end:{line:1,column:n.length}},raw:n,value:e}}function Cr(e){let t=`'${e}'`,n;if(t.includes(` +`)){let e=t.split(/\r\n|\n/),r=e[e.length-1];n={line:e.length,column:r.length}}else n={line:1,column:t.length};return{type:`String`,loc:{start:E(),end:n},raw:t,value:e}}function wr(e,t){let n=dr(e,t,`multiline-basic`),r=`"""`+(t.startsWith(`"""\r +`)?`\r +`:t.startsWith(`""" +`)?` +`:``)+n+`"""`,i=t.slice(3,-3);if(/(^|[^\\])(\\\\)*\\$/m.test(i)){let e=ar(t,n);e!==null&&(r=e)}let a=Er(r);return{type:`String`,loc:{start:E(),end:a},raw:r,value:e}}function Tr(e,t){let n=`'''`+(t.startsWith(`'''\r +`)?`\r +`:t.startsWith(`''' +`)?` +`:``)+e+`'''`,r=Er(n);return{type:`String`,loc:{start:E(),end:r},raw:n,value:e}}function Er(e){if(e.includes(` +`)){let t=e.split(/\r\n|\n/),n=t[t.length-1];return{line:t.length,column:n.length}}return{line:1,column:e.length}}function Dr(e){let t=e.toString();return{type:`Integer`,loc:{start:E(),end:{line:1,column:t.length}},raw:t,value:e}}function Or(e,t=1){let n,r;if(e===1/0)n=`inf`;else if(e===-1/0)n=`-inf`;else if(Number.isNaN(e)){let t=new Float64Array([e]),i=!!(new DataView(t.buffer).getUint32(4,!0)&2147483648);r=i?`-`:void 0,n=i?`-nan`:`nan`}else if(Object.is(e,-0))n=`-0.`+`0`.repeat(Math.max(t,1));else{n=e.toString();let r=n.search(/[eE]/);if(r!==-1){let e=n.slice(0,r),i=n.slice(r),a=e.indexOf(`.`);if(a===-1)n=`${e}.${`0`.repeat(Math.max(t,1))}${i}`;else{let r=e.length-a-1;n=r{if(!a(e))return!1;let n=d(e.value),r=l(e.value)&&e.value.items.length>0&&e.value.items.every(e=>d(e.item));if(n||r){let n=Gr(e.key.value);return t.inlineTableStart===void 0||n{H(e,e,n),d(n.value)?n.value.items.length===0?V(e,e,G(n.key.value)):V(e,e,Lr(n,t.bracketSpacing)):Rr(n,t.bracketSpacing).forEach(t=>{V(e,e,t)})}),U(e),e)}function Lr(e,t){let n=G(e.key.value);Pn(n);for(let t of e.value.items)V(n,n,t.item,void 0,void 0,void 0,void 0,!0);return U(n),Hr(n,1,t),n}function Rr(e,n){let i=fr();Pn(i);for(let t of e.value.items){let n=mr(e.key.value);V(i,i,n);for(let e of t.item.items)V(i,n,e.item,void 0,void 0,void 0,void 0,!0)}U(i);for(let e of i.items)(t(e)||r(e))&&Hr(e,1,n);return i.items}function zr(e){let t=gn(e);return t===void 0?Tn(e):t}function Br(e){return Math.max(e,2)}function Vr(e,t,n,r=!0){let i=zr(e),o=!1;if(i){let i=t+n;for(let t of e.items)W(t,{lines:0,columns:i-t.loc.start.column}),u(t)&&(l(t.item)||d(t.item)?Vr(t.item,t.item.loc.start.column,Br(n),r)&&(t.loc.end={...t.item.loc.end},o=!0):a(t.item)&&(l(t.item.value)||d(t.item.value))&&Vr(t.item.value,t.item.loc.start.column,n,r)&&(t.item.loc.end={...t.item.value.loc.end},t.loc.end={...t.item.loc.end},o=!0));e.loc.end.column=t+1,o=!0}for(let t of e.items)if(u(t)){if(l(t.item)||d(t.item))Vr(t.item,t.item.loc.start.column,Br(n),r)&&(t.loc.end={...t.item.loc.end},o=!0);else if(a(t.item)&&(l(t.item.value)||d(t.item.value))){let e=t.item.value,i=t.item.loc.start.column,a=zr(e);Vr(e,i,n,r)&&(a&&(e.loc.end.column=i+1),t.item.loc.end={...e.loc.end},t.loc.end={...t.item.loc.end},o=!0)}}let s=fe(e)??2;for(let t=1;t0){let t=e.items[e.items.length-1];e.loc.end={line:t.loc.end.line,column:t.loc.end.column+ +!!t.comma+(r?2:1)}}return o}function Hr(e,t,n=!0){let r=a(e)?[e]:e.items;for(let e of r)a(e)&&(l(e.value)||d(e.value))&&Vr(e.value,e.loc.start.column,t,n)}function Ur(e,t){let n=(e,r)=>{if(r&&(delete e.range,gn(e)===!0||Tn(e))){let n=e.loc.start.column+t;for(let t of e.items)W(t,{lines:0,columns:n-t.loc.start.column});e.loc.end.column=e.loc.start.column+1}for(let t of e.items)u(t)&&(l(t.item)||d(t.item)?n(t.item,l(e)&&d(t.item)):a(t.item)&&(l(t.item.value)||d(t.item.value))&&n(t.item.value,!1))};n(e,!1)}function Wr(e){if(e.items.length>0){let t=e.items[e.items.length-1];e.loc.end.line=t.loc.end.line,e.loc.end.column=t.loc.end.column}else e.loc.end.line=e.key.loc.end.line,e.loc.end.column=e.key.loc.end.column}function Gr(e){return Math.max(0,e.length-1)}function Kr(e,t){if(t.inlineTableStart===void 0||t.inlineTableStart===0)return e;let n=[];for(let i of e.items)if(a(i)&&d(i.value)){if(Gr(i.key.value)0&&U(e)}function Jr(e){let t=0,n=0;for(let r of e.items)n===0&&r.loc.start.line>1?t=1-r.loc.start.line:r.loc.start.line+t>n+2&&(t+=n+2-(r.loc.start.line+t)),W(r,{lines:t,columns:0}),n=r.loc.end.line;return e}function Yr(e){return e.items.length>0&&e.loc.end.line-e.loc.start.line+1>e.items.length}function Xr(e){return l(e)||d(e)}function Zr(e,t,n){if(!u(t)||!Xr(t.item)||!Yr(e))return;let r=e.items.find(e=>Xr(e.item)&&e.item.type===t.item.type&&Yr(e.item));if(!r||!Xr(r.item))return;let i=t.item,a=r.item,o=a.items.length>0?a.items[0].loc.start.column-a.loc.start.column:n,s=a.items.length>0?a.items[0].loc.start.line-a.loc.start.line:1,c=a.items.length>0?a.loc.end.line-a.items[a.items.length-1].loc.end.line:1,l=i.loc.start.line,d=i.loc.start.column,f=l+s;for(let e of i.items)W(e,{lines:f-e.loc.start.line,columns:d+o-e.loc.start.column}),f=e.loc.end.line+1;i.loc.end={line:f-1+c,column:a.loc.end.column},_n(i),t.loc={start:w(i.loc.start),end:w(i.loc.end)}}function Qr(e,t){let n=e=>{let r=!1;for(let i of e.items){if(!u(i)||!Xr(i.item))continue;let a=i.item;if(d(a)&&Tn(a)){delete i.range,delete a.range;let n=a.loc.start.column+Math.max(t,2);for(let e of a.items)W(e,{lines:0,columns:n-e.loc.start.column});a.loc.end.column=a.loc.start.column+1,yn(a),_n(a),r=!0,xn(e),i.comma=!0,i.loc.end={line:a.loc.end.line,column:a.loc.end.column},e.loc.end={line:a.loc.end.line,column:a.loc.end.column+(i.comma?2:1)}}l(a)&&n(a)&&(delete a.range,delete i.range,i.loc.end={line:a.loc.end.line,column:a.loc.end.column},r=!0,xn(e))}if(r){for(let t=1;t1&&Kr(i,t),t.multilineTable!==`auto`||t.multilineArray!==`auto`)for(let e of i.items)(e.type===`Table`||e.type===`TableArray`||a(e)&&(l(e.value)||d(e.value)))&&Hr(e,t.indentWidth,t.bracketSpacing);return Jr(i)}function*$r(e,t,n=0,r=!1){for(let i of Object.keys(e)){let a=e[i];if(a===void 0)continue;let o=ri(a);o!==void 0&&(yield gr([i],ei(o,t,n,r),n>0&&!r))}}function ei(e,t,n,r){let i=t.minimumDecimals??0;if(e===null)throw Error(`"null" values are not supported`);if(e===void 0)throw Error(`"undefined" values are not supported inside arrays`);return ot(e)?br(e):ct(e)?Dr(e):st(e)?i>0?Or(e,i):Dr(e):lt(e)?Or(e,Math.max(i,1)):ut(e)?kr(e):pt(e)?jr(e,t.truncateZeroTimeInDates):dt(e)?Ar(e,t.truncateZeroTimeInDates):Array.isArray(e)?ti(e,t,n,r):ni(e,t,n,r)}function ti(e,t,n,r){let i=Mr(),a=!(e.length===0&&n>0)&&mn(`array`,n,r,t);hn(i,a),Mn(i,t.indentWidth),Pn(i);for(let r of e){let e=ei(r,t,n+1,a),o=K(e);!a&&(e.type===`InlineArray`||e.type===`InlineTable`)&&gn(e)===!0&&_n(e),Zr(i,o,t.indentWidth),V(i,i,o)}return er(i,i,t.bracketSpacing),tr(i,i,t.trailingComma),U(i),i}function ni(e,t,n,r){if(e=ri(e),!F(e))return ei(e,t,n,r);let i=Nr(),a=!(Object.keys(e).length===0&&n>0)&&mn(`table`,n,r,t);hn(i,a),Mn(i,t.indentWidth),Pn(i);for(let r of $r(e,t,n+1,a))V(i,i,K(r));return er(i,i,t.bracketSpacing),tr(i,i,t.trailingComma),U(i),i}function ri(e){return!e||dt(e)||pt(e)?e:typeof e.toJSON==`function`?e.toJSON():e}const ii=/(\r\n|\n)/g;function ai(e,t){let n=[],r=e=>{let t={line:e.loc.end.line,column:e.loc.end.column-1},n=e.items[e.items.length-1];return n&&(n.loc.end.line>t.line||n.loc.end.line===t.line&&n.loc.end.column>t.column)&&(t.line=n.loc.end.line,t.column=n.loc.end.column+ +!!n.comma),t};function i(e){switch(e.type){case`Document`:for(let t=0;t{if(e.type===`String`&&e.loc.end.line>e.loc.start.line)for(let n=e.loc.start.line+1;n<=e.loc.end.line;n++)t.add(n);if(p(e))for(let t of e.items)r(t);a(e)&&r(e.value),u(e)&&r(e.item)};if(ht(e))for(let t of e)r(t);else r(e);for(let e=0;e{if(e.type===`Comment`&&c.push(e),a(e))_(e.value);else if(u(e))_(e.item);else if(e.type===`Table`||e.type===`TableArray`)for(let t of e.items)_(t);else if(p(e))for(let t of e.items)_(t)};for(let e of i)_(e);let v=new WeakSet,y=e=>{for(let n of e)if(a(n)){let e=n.value;(d(e)||l(e))&&v.add(e)}else(t(n)||r(n))&&y(n.items)};y(i);let b=e=>(n.useTabsForIndentation?` `:` `).repeat(e),x=e=>{if(e.length===0)return;o.push(e);let t=e.split(/\r\n|\n/);t.length===1?g+=e.length:(h+=t.length-1,g=t[t.length-1].length)},S=(e,t)=>{e>h?(x(n.newLine.repeat(e-h)),t>0&&x(b(t))):e===h&&t>g?x(g===0?b(t):` `.repeat(t-g)):o.length>0&&(e0&&x(b(t)))},C=e=>{switch(e.type){case`Key`:return e.raw;case`String`:return e.raw;case`Integer`:return e.raw;case`Float`:return e.raw;case`Boolean`:return e.value.toString();case`DateTime`:return e.raw;case`Comment`:return e.raw;default:return}},w=e=>{if(a(e))return[e.key,e.value];if(u(e)||m(e))return[e.item];if(e.type===`Table`||e.type===`TableArray`){let t=e;return[t.key,...t.items]}return p(e)?e.items:[]},T=e=>{let t=[e];for(;t.length>0;){let e=t.pop();if(e.dirty)return!1;let n=C(e);if(n!==void 0){let t=z(e);if(!t||!e.range||t.slice(e.range[0],e.range[1])!==n)return!1}else if(!en(e))return!1;t.push(...w(e))}return!0},E=e=>e.type!==`Document`&&e.type!==`InlineItem`&&e.type!==`TableKey`&&e.type!==`TableArrayKey`&&(C(e)!==void 0||en(e)),D=(e,t,r=!1)=>{t.line>e.line?(x(n.newLine.repeat(t.line-e.line)),t.column>0&&x(r?` `.repeat(t.column):b(t.column))):t.line===e.line&&t.column>e.column&&x(` `.repeat(t.column-e.column))},O=(e,t,n)=>{var r,i;let a=z(e);if(!a||!e.range||l(e)&&Sn(e)&&(!n||t&&!t.range||n&&!n.range)||t&&z(t)!==a||n&&z(n)!==a)return;let o=(t==null||(r=t.range)==null?void 0:r[1])??e.range[0]+1,s=(n==null||(i=n.range)==null?void 0:i[0])??e.range[1]-1;if(o===void 0||s===void 0||s{let n=z(t);return n!==void 0&&z(e)===n&&t.range!==void 0&&e.range!==void 0&&e.range[0]>=t.range[0]&&e.range[1]<=t.range[1]},ee=e=>{let t=d(e)&&bn(e),r;for(let n of e.items){let i=r?{line:r.loc.end.line,column:r.loc.end.column+ +!!r.comma}:{line:e.loc.start.line,column:e.loc.start.column+1},a=c.filter(t=>!f.has(t)&&k(t,e)&&t.loc.start.line>=i.line&&t.loc.end.line<=n.loc.start.line&&(t.loc.start.line>i.line||t.loc.start.column>=i.column)&&(t.loc.end.line=e.loc.start.line&&t.loc.end.line<=e.loc.end.line).sort((e,t)=>e.loc.start.line-t.loc.start.line||e.loc.start.column-t.loc.start.column);if(a.length>0){for(let e of a)D(i,e.loc.start),x(e.raw),f.add(e),i=e.loc.end;D(i,n.loc.start)}else{let a=O(e,r,n);if(a!==void 0)x(a);else{let a=z(n)!==z(e);if(r&&a&&n.loc.start.line===r.loc.end.line){let t=fe(e)??2,n=Math.max(0,t-1);n>0&&x(` `.repeat(n))}else D(i,n.loc.start,t)}}A(n,!1);for(let t of c){if(f.has(t)||!k(t,e)||t.loc.start.line!==n.loc.end.line||t.loc.start.columne.loc.end.line)continue;let r=n.loc.end.column+ +!!n.comma;t.loc.start.column>r&&x(` `.repeat(t.loc.start.column-r)),x(t.raw),f.add(t)}r=n}let i=r?{line:r.loc.end.line,column:r.loc.end.column+ +!!r.comma}:{line:e.loc.start.line,column:e.loc.start.column+1},a=c.filter(t=>!f.has(t)&&k(t,e)&&t.loc.start.line>=i.line&&t.loc.end.line<=e.loc.end.line).sort((e,t)=>e.loc.start.line-t.loc.start.line||e.loc.start.column-t.loc.start.column);if(a.length>0){for(let e of a)D(i,e.loc.start),x(e.raw),f.add(e),i=e.loc.end;D(i,{line:e.loc.end.line,column:e.loc.end.column-1})}else{let a=O(e,r,void 0);if(a!==void 0)x(a);else if(r)t&&r.comma&&x(` `),D(i,{line:e.loc.end.line,column:e.loc.end.column-1},t);else{let t=z(e);if(t&&e.range){let r=t.slice(e.range[0],e.range[1]),i=Math.max(r.lastIndexOf(` +`),r.lastIndexOf(`\r`)),a=v.has(e)||wn(e);if(i!==-1&&a&&cn(e)){var o;let e=((o=r.slice(i+1,-1).match(/^[\t ]*/))==null?void 0:o[0])??``;x(n.newLine+e)}}}}},A=(e,t=!0)=>{if(e.type===`Comment`&&f.has(e))return;let n=z(e);if(!(n!==void 0&&e.range&&s.some(t=>t.source===n&&t.start<=e.range[0]&&t.end>=e.range[1]))){if(E(e)&&T(e)&&e.range&&n!==void 0){let r=n.slice(e.range[0],e.range[1]),i=C(e);if(i===void 0||i===r){t&&S(e.loc.start.line,e.loc.start.column),x(r),s.push({source:n,start:e.range[0],end:e.range[1]});return}}switch(e.type){case`Document`:for(let t of e.items)A(t);break;case`Table`:{let n=e;A(n.key,t);for(let e of n.items)A(e);break}case`TableKey`:{let n=e;t&&S(n.loc.start.line,n.loc.start.column),x(`[${n.item.raw}]`);break}case`TableArray`:{let n=e;A(n.key,t);for(let e of n.items)A(e);break}case`TableArrayKey`:{let n=e;t&&S(n.loc.start.line,n.loc.start.column),x(`[[${n.item.raw}]]`);break}case`KeyValue`:{let n=e;t&&S(n.loc.start.line,n.loc.start.column),A(n.key,!1),D(n.key.loc.end,{line:n.loc.start.line,column:n.equals}),x(`=`),D({line:n.loc.start.line,column:n.equals+1},n.value.loc.start),A(n.value,!1);break}case`Key`:t&&S(e.loc.start.line,e.loc.start.column),x(e.raw);break;case`String`:t&&S(e.loc.start.line,e.loc.start.column),x(e.raw);break;case`Integer`:t&&S(e.loc.start.line,e.loc.start.column),x(e.raw);break;case`Float`:t&&S(e.loc.start.line,e.loc.start.column),x(e.raw);break;case`Boolean`:t&&S(e.loc.start.line,e.loc.start.column),x(e.value.toString());break;case`DateTime`:t&&S(e.loc.start.line,e.loc.start.column),x(e.raw);break;case`InlineArray`:{let n=e;t&&S(n.loc.start.line,n.loc.start.column),x(`[`),ee(n),x(`]`);break}case`InlineTable`:{let n=e;t&&S(n.loc.start.line,n.loc.start.column),x(`{`),ee(n),x(`}`);break}case`InlineItem`:{let t=e;if(A(t.item,!1),t.comma){let e=z(t);if(e&&t.range&&t.item.range&&z(t.item)===e){let n=e.slice(t.item.range[1],t.range[1]);/^[\t ]*$/.test(n)&&x(n)}x(`,`)}break}case`Comment`:t&&S(e.loc.start.line,e.loc.start.column),x(e.raw);break;default:throw Error(`toTOMLCursor: Unrecognized node type: ${String(e.type)}`)}}};for(let e of i)A(e);return o.join(``).replace(/\r\n|\n/g,n.newLine)+n.newLine.repeat(n.trailingNewline)}function si(e,t){let n=Array.isArray(e)?e:ht(e)?Array.from(e):[e],r=[],i=1,a=0,o=e=>(t.useTabsForIndentation?` `:` `).repeat(e),s=e=>{if(e.length===0)return;r.push(e);let t=0,n=-1,o=e.indexOf(` +`);for(;o!==-1;)t++,n=o,o=e.indexOf(` +`,o+1);if(t===0){a+=e.length;return}i+=t,a=e.length-n-1},c=(e,n)=>{e>i?(s(t.newLine.repeat(e-i)),n>0&&s(o(n))):e===i&&n>a?s(a===0?o(n):` `.repeat(n-a)):r.length>0&&(e0&&s(o(n)))},l=e=>{switch(e.type){case`Key`:return e.raw;case`String`:return e.raw;case`Integer`:return e.raw;case`Float`:return e.raw;case`Boolean`:return e.value.toString();case`DateTime`:return e.raw;case`Comment`:return e.raw;default:return``}},u=[],d=(e,t)=>{switch(e.type){case`Document`:{let t=e.items;for(let e=t.length-1;e>=0;e--)u.push([0,t[e],!0]);break}case`Table`:{let n=e,r=n.items;for(let e=r.length-1;e>=0;e--)u.push([0,r[e],!0]);u.push([0,n.key,t]);break}case`TableKey`:{let n=e;u.push([1,`[${n.item.raw}]`]),t&&u.push([3,n.loc.start.line,n.loc.start.column]);break}case`TableArray`:{let n=e,r=n.items;for(let e=r.length-1;e>=0;e--)u.push([0,r[e],!0]);u.push([0,n.key,t]);break}case`TableArrayKey`:{let n=e;u.push([1,`[[${n.item.raw}]]`]),t&&u.push([3,n.loc.start.line,n.loc.start.column]);break}case`KeyValue`:{let n=e;u.push([0,n.value,!1]),u.push([2,n.loc.start.line,n.equals+1,n.value.loc.start.line,n.value.loc.start.column,!1]),u.push([1,`=`]),u.push([2,n.key.loc.end.line,n.key.loc.end.column,n.loc.start.line,n.equals,!1]),u.push([0,n.key,!1]),t&&u.push([3,n.loc.start.line,n.loc.start.column]);break}case`Key`:case`String`:case`Integer`:case`Float`:case`Boolean`:case`DateTime`:case`Comment`:u.push([1,l(e)]),t&&u.push([3,e.loc.start.line,e.loc.start.column]);break;case`InlineArray`:{let n=e,r=n.items;if(u.push([1,`]`]),r.length>0){let e=r[r.length-1];u.push([2,e.loc.end.line,e.loc.end.column+ +!!e.comma,n.loc.end.line,n.loc.end.column-1,!1])}for(let e=r.length-1;e>=0;e--){let t=r[e],i=e>0?r[e-1]:void 0;u.push([0,t,!1]),u.push(i?[2,i.loc.end.line,i.loc.end.column+ +!!i.comma,t.loc.start.line,t.loc.start.column,!1]:[2,n.loc.start.line,n.loc.start.column+1,t.loc.start.line,t.loc.start.column,!1])}u.push([1,`[`]),t&&u.push([3,n.loc.start.line,n.loc.start.column]);break}case`InlineTable`:{let n=e,r=n.items,i=bn(n);if(u.push([1,`}`]),r.length>0){let e=r[r.length-1];i&&e.comma&&u.push([1,` `]),u.push([2,e.loc.end.line,e.loc.end.column+ +!!e.comma,n.loc.end.line,n.loc.end.column-1,i])}for(let e=r.length-1;e>=0;e--){let t=r[e],a=e>0?r[e-1]:void 0;u.push([0,t,!1]),u.push(a?[2,a.loc.end.line,a.loc.end.column+ +!!a.comma,t.loc.start.line,t.loc.start.column,i]:[2,n.loc.start.line,n.loc.start.column+1,t.loc.start.line,t.loc.start.column,i])}u.push([1,`{`]),t&&u.push([3,n.loc.start.line,n.loc.start.column]);break}case`InlineItem`:{let t=e;t.comma&&u.push([1,`,`]),u.push([0,t.item,!1]);break}default:throw Error(`toTOMLSequential: Unrecognized node type: ${String(e.type)}`)}};for(let e=n.length-1;e>=0;e--)u.push([0,n[e],!0]);for(;u.length>0;){let e=u.pop();switch(e[0]){case 0:d(e[1],e[2]);break;case 1:s(e[1]);break;case 2:{let n=e[1],r=e[2],i=e[3],a=e[4],c=e[5];i>n?(s(t.newLine.repeat(i-n)),a>0&&s(c?` `.repeat(a):o(a))):i===n&&a>r&&s(` `.repeat(a-r));break}case 3:c(e[1],e[2])}}return r.join(``).replace(/\r\n|\n/g,t.newLine)+t.newLine.repeat(t.trailingNewline)}function ci(e,t,n){if(t.start.line===t.end.line){let r=ui(e,t.start.line),i=r.substring(0,t.start.column),a=i.lengthe!==` +`&&e!==`\r +`),i=t.end.line-t.start.line+1;if(r.length!==i)throw Error(`Mismatch between location and raw string, expected ${i} lines for "${n}"`);for(let n=t.start.line;n<=t.end.line;n++){let i=ui(e,n),a=n===t.start.line,o=n===t.end.line,s=``;if(a){let e=i.substring(0,t.start.column);s=e.length=BigInt(-(2**53-1))&&i<=BigInt(2**53-1)?Number(i):i}function fi(e,t,n){for(let r of e.items){let e=r.item;if(e.value.type===`InlineTable`){let r=t.concat(e.key.value);n.add(xi(r)),fi(e.value,r,n)}}}function pi(e){var t,n,r,i,a,o;if((t=e.constructor)!=null&&(t=t.name)!=null&&(n=t.startsWith)!=null&&n.call(t,`Temporal.`)||((r=e.constructor)==null?void 0:r.name)===`PlainDate`||((i=e.constructor)==null?void 0:i.name)===`PlainTime`||((a=e.constructor)==null?void 0:a.name)===`PlainDateTime`||((o=e.constructor)==null?void 0:o.name)===`ZonedDateTime`)return e;let s=globalThis.Temporal;if(e instanceof ge)return s.PlainDate.from(e.toISOString());if(e instanceof _e)return s.PlainTime.from(e.toISOString());if(e instanceof ve)return s.PlainDateTime.from(e.toISOString().replace(` `,`T`));if(e instanceof ye){let t=e.toISOString().replace(` `,`T`),n=t.match(/([+-]\d{2}:\d{2}|Z)$/),r=n?n[1]:`Z`,i=t.replace(/([+-]\d{2}:\d{2}|Z)$/,``),a=r===`Z`?`+00:00`:r;return s.ZonedDateTime.from(`${i}${r}[${a}]`)}return s.PlainDateTime.from(e.toISOString().replace(`Z`,``))}function J(e,t=``,n={}){let{integersAsBigInt:r=`asNeeded`,temporal:i=!1}=n,a=at(),o=new Set,s=new Set,c=new Set,l=new Set,u=new Set,d=a,f=[],p={tables:o,table_arrays:s,defined:c,implicit_tables:l,inline_tables:u};for(let t of e)switch(t.type){case`Table`:m(t);for(let e of t.items)e.type===`KeyValue`&&g(e);break;case`TableArray`:h(t);for(let e of t.items)e.type===`KeyValue`&&g(e);break;case`KeyValue`:g(t)}return a;function m(e){let n=e.key.item.value;try{hi(a,[],n,e.type,p)}catch(n){let r=n;throw new D(t,e.key.loc.start,r.message)}let r=xi(n);o.add(r),c.add(r),d=gi(a,n),f=n}function h(e){let n=e.key.item.value;try{hi(a,[],n,e.type,p)}catch(n){let r=n;throw new D(t,e.key.loc.start,r.message)}let r=xi(n);s.add(r),c.add(r),d=_i(a,n),f=n}function g(e){let n=e.key.value;try{hi(d,f,n,e.type,p)}catch(n){let r=n;throw new D(t,e.key.loc.start,r.message)}if(n.length>1)for(let e=1;e1?gi(d,n.slice(0,-1)):d;o[P(n)]=a,c.add(xi(f.concat(n)))}}function mi(e,t={}){let{integersAsBigInt:n=`asNeeded`,temporal:r=!1}=t;switch(e.type){case`InlineTable`:let t=at(),i=new Set,a=new Map;return e.items.forEach(({item:e})=>{let o=e.key.value,s=mi(e.value,{integersAsBigInt:n,temporal:r}),c=bi(o),l=xi(o);if(i.has(l))throw Error(`Duplicate key "${c}" in inline table`);for(let e=1;e1?gi(t,o.slice(0,-1)):t;u[P(o)]=s}),t;case`InlineArray`:return e.items.map(e=>mi(e.item,{integersAsBigInt:n,temporal:r}));case`DateTime`:if(r){if(globalThis.Temporal===void 0)throw Error(`Temporal API is not available in this runtime. Set temporal: false or use a runtime with Temporal support.`);return pi(e.value)}return e.value;case`String`:return e.value;case`Integer`:return di(e.raw,n);case`Float`:case`Boolean`:return e.value;default:throw Error(`Unrecognized value type "${e.type}"`)}}function hi(e,t,n,r,i){let a=t.length>0?[...t]:[],o=Array(n.length),s=Array(n.length);for(let e=0;e1)for(let e=1;e1)for(let e=1;e1)for(let e=1;e<=n.length;e++){let t=o[e-1];if(i.tables.has(s[e-1]))throw Error(`Cannot extend explicit table ${t} with dotted keys`)}let u=0;for(let t of n){if(!bt(e,t))return;if(yi(e[t]))throw Error(`Value already defined for ${o[u]}`);let r=o[u];if(Array.isArray(e[t])&&!i.table_arrays.has(s[u]))throw Error(`Cannot add to static array at ${r}`);let a=u++(e[t]||(e[t]=at()),Array.isArray(e[t])?P(e[t]):e[t]),e)}function yi(e){return typeof e!=`object`&&!dt(e)}function bi(e){return e.join(`.`)}function xi(e){return JSON.stringify(e)}function Si(e){return e.type===`Add`}function Ci(e){return e.type===`Edit`}function Y(e){return e.type===`Remove`}function wi(e){return e.type===`Move`}function Ti(e){return e.type===`Rename`}function Ei(e,t,n=[],r={}){if(e===t||xt(e,t))return[];if(typeof e==`number`&&typeof t==`number`&&Number.isNaN(e)&&Number.isNaN(t)){let n=new Float64Array([e]),r=new Float64Array([t]),i=new DataView(n.buffer),a=new DataView(r.buffer);if((i.getUint32(4,!0)&2147483648)==(a.getUint32(4,!0)&2147483648))return[]}return Array.isArray(e)&&Array.isArray(t)?ji(e,t,n,r):F(e)&&F(t)?Di(e,t,n,r):[{type:`Edit`,path:n}]}function Di(e,t,n=[],r={}){let i=[],a=Object.keys(e),o=a.map(t=>St(e[t])),s=Object.keys(t),c=s.map(e=>St(t[e])),l=new Map(s.map((e,t)=>[e,c[t]])),u=new Set(a),d=new Set(s),f=(e,t,n)=>{let r=new Map;return e.forEach((e,i)=>{if(n.has(e))return;let a=r.get(t[i]);a?a.push(e):r.set(t[i],[e])}),r},p=f(a,o,d),m=f(s,c,u),h=new Map,g=new Set,_=new Map;for(let[e,t]of p){let n=m.get(e);if(!n)continue;let r=Math.min(t.length,n.length);for(let e=0;e{let c=n.concat(a);if(d.has(a)){let n=e[a],u=t[a],d=typeof n==`number`&&!Number.isFinite(n)||typeof u==`number`&&!Number.isFinite(u);(r.updateOrder||d||o[s]!==l.get(a))&&Ct(i,Ei(e[a],t[a],c,r))}else h.has(a)?i.push({type:`Rename`,path:n,from:a,to:h.get(a)}):i.push({type:`Remove`,path:c})}),s.forEach(e=>{if(!u.has(e)&&!g.has(e)){let t={type:`Add`,path:n.concat(e)};if(r.orderSource!==void 0){let i=r.orderSource;for(let e of n)i=i==null?void 0:i[e];if(F(i)){let n=Object.keys(i),r=n.indexOf(e);for(let e=r+1;et.has(e))}}let y=i.filter(e=>Si(e)&&e.path.length===n.length+1||Ti(e)&&e.path.length===n.length);if(r.orderSource!==void 0&&y.length>1){let e=[...y].sort((e,t)=>{let n=Si(e)?e.path[e.path.length-1]:Ti(e)?e.to:``,r=Si(t)?t.path[t.path.length-1]:Ti(t)?t.to:``;return v.indexOf(n)-v.indexOf(r)}),t=0;i=i.map(n=>y.includes(n)?e[t++]:n)}if(r.updateOrder){let e=[];for(let t of a)if(d.has(t))e.includes(t)||e.push(t);else if(h.has(t)){let n=h.get(t);e.includes(n)||e.push(n)}for(let t of s)e.includes(t)||e.push(t);s.forEach((t,r)=>{if(e[r]===t)return;let a=e.indexOf(t);i.push({type:`Move`,path:n,from:a,to:r,key:t}),e.splice(a,1),e.splice(r,0,t)})}return i}function Oi(e){if(typeof e==`string`)return e.includes(` +`);if(Array.isArray(e))return e.length>0;if(typeof e==`object`&&e&&!(e instanceof Date)){let t=Object.values(e);return t.some(e=>typeof e==`object`&&!!e)||t.some(Oi)}return!1}function ki(e){return typeof e==`object`&&!!e&&!Array.isArray(e)&&!(e instanceof Date)}function Ai(e){return e.length>4&&e.some(e=>typeof e==`string`)&&e.some(ki)}function ji(e,t,n=[],r={}){let i=[],a=e.map(St),o=t.map(St),s=e.some(Oi)||t.some(Oi)||Ai(e)||Ai(t),c=e.slice(),l=0,u=!1,d=e=>{let t={type:`Remove`,path:n.concat(e)};return(!u||l>0)&&Object.defineProperty(t,"coordinate",{value:`source`}),t};for(let e=0;e=a.length;if(!p&&a[e]===f)continue;let m=p?-1:a.indexOf(f,e+1);if(m>-1){let r=a[e],p=0;for(let t=e;th;if(s&&(o.indexOf(a[e])===-1||g)){i.push(d(e+l)),a.splice(e,1),c.splice(e,1),l++,e--;continue}if(l>0&&g){i.push(d(e+l)),a.splice(e,1),c.splice(e,1),l++,e--;continue}if(s&&l>0){i.push({type:`Add`,path:n.concat(e)}),u=!0,a.splice(e,0,f),c.splice(e,0,t[e]),l--;continue}i.push({type:`Move`,path:n,from:m,to:e});let _=a.splice(m,1);a.splice(e,0,..._);let v=c.splice(m,1);c.splice(e,0,...v);continue}let h=!1;if(!p){let t=a[e],n=0;for(let r=e;rr}if(!p&&h){Ct(i,Ei(c[e],t[e],n.concat(e),r)),a[e]=f,c[e]=t[e];continue}i.push({type:`Add`,path:n.concat(e)}),u=!0,a.splice(e,0,f),c.splice(e,0,t[e]),l>0&&l--}for(let e=o.length;e{try{let f=[];if(a(l))f=l.key.value;else if(t(l))f=l.key.item.value;else if(r(l)){f=l.key.item.value;let e=St(f);o[e]||(o[e]=0);let t=o[e]++;f=f.concat(t)}else u(l)&&a(l.item)?f=l.item.key.value:u(l)&&(f=[d]);if(f.length&&I(f,i.slice(0,f.length))){let t=i.slice(f.length);if(e(n)&&r(l)&&t.length>0){let e=l.key.item.value,i=n,a=i.items.findIndex((t,n)=>n>d&&r(t)&&I(t.key.item.value,e)),o=a===-1?i.items.length:a;s=Mi(l,t,e,i.items.slice(d+1,o))}else s=u(l)&&a(l.item)?i.length===f.length?l:X(l.item.value,t):u(l)&&i.length>f.length?X(l.item,t):X(l,t);return!0}return(a(l)||u(l)&&a(l.item))&&f.length>i.length&&I(f.slice(0,i.length),i)&&(c||=l),!1}catch{return!1}}),!s&&c&&(s=c),!s)throw Error(`Node not found at ${i.join(`.`)}`);return s}function Mi(e,n,i,a){try{return X(e,n)}catch{}let o={};for(let e of a){let a=[];if(t(e)){let t=e.key.item.value;t.length>i.length&&I(t.slice(0,i.length),i)&&(a=t.slice(i.length))}else if(r(e)){let t=e.key.item.value;if(t.length>i.length&&I(t.slice(0,i.length),i)){let e=t.slice(i.length),n=St(e);o[n]||(o[n]=0);let r=o[n]++;a=e.concat(r)}}if(a.length&&I(a,n.slice(0,a.length)))try{return X(e,n.slice(a.length))}catch{}}throw Error(`Node not found at (aot scope) ${n.join(`.`)}`)}function Z(e,t){try{return X(e,t)}catch{}}function Ni(e,t){let n=t,r;for(;n.length&&!r;)n=n.slice(0,-1),r=Z(e,n);if(!r)throw Error(`Parent not found for ${t.join(`.`)}`);return r}const Pi=String.raw`(?:[\w-]+|"[^"]*"|'[^']*')`,Fi=new RegExp(String.raw`^#\s*${Pi}(?:\s*\.\s*${Pi})*\s*=`),Ii=String.raw`(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|"""[\s\S]*?"""|'''[\s\S]*?'''|[^\s#]+)`,Li=new RegExp(String.raw`^#\s*${Pi}(?:\s*\.\s*${Pi})*\s*=\s*${Ii}\s*$`),Ri=new RegExp(String.raw`^#\s*\[\[?\s*${Pi}(?:\s*\.\s*${Pi})*\s*\]\]?\s*$`);function zi(e){return Li.test(e.raw)||Ri.test(e.raw)}function Bi(e){let t=e.raw.indexOf(`=`);if(t<0)return!1;let n=!1,r=!1;for(let i=t+1;i=0&&(n=n.substring(0,r)),(n.startsWith(`"`)&&n.endsWith(`"`)||n.startsWith(`'`)&&n.endsWith(`'`))&&(n=n.slice(1,-1)),n||void 0}function Ui(e){return(d(e)||l(e))&&e.loc.end.line>e.loc.start.line}function Wi(e){if(a(e))return e.key.value[0];if(t(e)||r(e))return e.key.item.value[0];if(u(e)&&a(e.item))return e.item.key.value[0]}function Gi(e,t,n,r=Wi){let i=[],a=t,o,s=[],c=()=>{s.length&&(i.push({kind:`pinned`,items:s,startLine:s[0].loc.start.line,endLine:P(s).loc.end.line}),s=[])};for(let t of e){if(f(t)){if(t.loc.start.line<=a){o?(o.items.push(t),o.endLine=Math.max(o.endLine,t.loc.end.line)):i.push({kind:`pinned`,items:[t],startLine:t.loc.start.line,endLine:t.loc.end.line});continue}let e=s.length?P(s).loc.end.line:void 0;s.length&&t.loc.start.line===e+1||c(),s.push(t);continue}let e=[];if(s.length){if(P(s).loc.end.line+1===t.loc.start.line&&n(t)){let n=r(t);if(n!==void 0){let e=-1;for(let t=s.length-1;t>=0;t--){let r=Vi(s[t])?Hi(s[t]):void 0;if(r!==void 0&&r!==n){e=t;break}}if(e>=0){let t=s.splice(0,e+1);i.push({kind:`pinned`,items:t,startLine:t[0].loc.start.line,endLine:P(t).loc.end.line})}}e=s,s=[]}else c()}let l=[...e,t],u={kind:`member`,member:t,key:r(t),items:l,startLine:l[0].loc.start.line,endLine:t.loc.end.line};i.push(u),o=u,a=t.loc.end.line}return c(),i}function Ki(t,n=()=>!0,r=Wi){let i=e(t)?0:t.key.loc.end.line;return Gi(t.items,i,n,r)}function qi(e,t){let n=t.filter(t=>f(t)&&(t.loc.start.line>e.loc.start.line||t.loc.start.line===e.loc.start.line&&t.loc.start.column>=e.loc.start.column)&&(t.loc.end.linee.loc.start.line-t.loc.start.line||e.loc.start.column-t.loc.start.column),e.loc.start.line,()=>!0)}function Ji(e,t){function n(e){if(a(e))return n(e.value);if(p(e)){for(let n of e.items)if(u(n)&&n.item===t)return n;for(let t of e.items){let e=n(u(t)?t.item:t);if(e)return e}}}return n(e)}function Yi(e,n){function i(e,t){if(e===n)return t;if(a(e))return i(e.value,t);if(d(e)||l(e))for(let r of e.items){if(r===n)return t;let e=i(r.item,t);if(e)return e}}function o(e){for(let s of e.items){if(s===n)return e;if(a(s)){let t=i(s.value,e);if(t)return t}else if(t(s)||r(s)){let e=o(s);if(e)return e}}}return o(e)}function Xi(e){for(let n=0;n0?i.items[e-1]:void 0;if(o&&(t(o)||r(o))){let e=Zi(o,a);if(e)for(let t of e)H(n,o,t)}}if(e(i)||t(i)||r(i)){let e=Ki(i).find(e=>e.member===a);if(e){let t=e.items.indexOf(a);for(let r=0;r<=t;r++){let t=e.items[r];i.items.includes(t)&&H(n,i,t)}for(let n=t+1;ne.member===a);if(t){let r=t.items.filter(e=>e!==a);for(let t of r){let n=e.items.indexOf(t);n<0||e.items.splice(n,1)}r.length&&(a.loc.start=t.items[0].loc.start,a.loc.end=P(t.items).loc.end),H(n,i,a,e.items),U(n);return}}}H(n,i,a)}function Qi(t,n,r,i){let s=!1;if(Ui(n)&&e(t)){let e=Yi(t,n);if(e){let c=qi(n,e.items);{let e=n.items,r=1;for(let n=1;n=3){s=!0;break}}else r=1}let f=[],m,h,g,_=[],v=(e,t)=>{if(!p(e))return;let n=e.items;for(let e=0;et;if(i&&_.push({node:r,start:w(r.loc.start),end:w(r.loc.end)}),r.loc.end.line>t&&r.loc.end.line>r.loc.start.line&&_.push({node:r,start:w(r.loc.start),end:w(r.loc.end),endOnly:!0}),u(r)){let e=r.item;if(i){let t=a(e)?e:void 0;_.push({node:e,start:w(e.loc.start),end:w(e.loc.end),equals:t?t.equals:void 0}),t&&(_.push({node:t.key,start:w(t.key.loc.start),end:w(t.key.loc.end)}),_.push({node:t.value,start:w(t.value.loc.start),end:w(t.value.loc.end)}))}e.loc.end.line>t&&e.loc.end.line>e.loc.start.line&&_.push({node:e,start:w(e.loc.start),end:w(e.loc.end),endOnly:!0}),v(e,t)}else p(r)&&v(r,t)}};for(let t of c){if(t.kind!==`member`||!t.member)continue;let n=t.items.filter(e=>e!==t.member);if(t.member===r&&(m=w(r.loc.start),h=w(r.loc.end),u(r)&&(g=w(r.item.loc.end)),v(u(r)?r.item:r,m.line),n.length&&(r.loc.start=w(t.items[0].loc.start),r.loc.end=w(P(t.items).loc.end))),n.length){f.push({owner:t.member,ownerOriginalStart:w(t.member.loc.start),comments:n});for(let t of n){let n=e.items.indexOf(t);n>=0&&e.items.splice(n,1)}}}let y=n.items.indexOf(r),b=y===n.items.length-1,x=y>0?n.items[y-1]:void 0,S=i===0&&y>0&&r.loc.end.line>r.loc.start.line&&x!==void 0&&x.loc.start.line===r.loc.start.line;H(t,n,r,e.items);let C=!1;if(b&&x!==void 0&&r.loc.end.line>r.loc.start.line&&!nr(n)&&B(t).get(x)&&(B(t).delete(x),C=!0),U(t),V(t,n,r,i,void 0,e.items),C){let e=B(t).get(r);e&&(e.lines=0,e.columns=0)}U(t),m&&h&&r.loc.end.line>r.loc.start.line&&(r.loc.end.column=h.column,u(r)&&g&&r.item.loc.end.line>r.item.loc.start.line&&(r.item.loc.end.column=g.column));for(let{node:e,start:t,end:n,endOnly:r,equals:i}of _){if(r){e.loc.end.column=n.column;continue}e.loc.start.column=t.column,e.loc.end.line===e.loc.start.line&&(e.loc.end.column=n.column),i!==void 0&&a(e)&&(e.equals=i)}for(let{owner:t,ownerOriginalStart:n,comments:i}of f){let a;t===r?(a={lines:r.loc.start.line-n.line,columns:r.loc.start.column-n.column},r.loc.start={line:m.line+a.lines,column:m.column+a.columns},r.loc.end={line:h.line+a.lines,column:h.column+a.columns}):a={lines:t.loc.start.line-n.line,columns:t.loc.start.column-n.column};for(let t of i){W(t,a);let n=e.items.findIndex(e=>e.loc.start.line>t.loc.start.line);n===-1?e.items.push(t):e.items.splice(n,0,t)}}let T=n;if(s||S){let e=(t,n)=>{let r;for(let n of t.items){if(!r){r={line:n.loc.end.line,column:n.loc.end.column+2};continue}let t={lines:r.line-n.loc.start.line,columns:r.column-n.loc.start.column};if(t.lines!==0||t.columns!==0){W(n,t),n.loc.end.line>n.loc.start.line&&(n.loc.end.column-=t.columns,u(n)&&n.item.loc.end.line>n.item.loc.start.line&&(n.item.loc.end.column-=t.columns));let r=u(n)?a(n.item)?n.item.value:n.item:n;(d(r)||l(r))&&r.loc.end.line>r.loc.start.line&&e(r,n)}r={line:n.loc.end.line,column:n.loc.end.column+2}}r&&(t.loc.end={line:r.line,column:r.column-1},n&&n.loc.end.line===t.loc.end.line&&(n.loc.end.column=t.loc.end.column))},n;for(let t of T.items){if(t===r){n={line:r.loc.end.line,column:r.loc.end.column+2};continue}if(!n)continue;let i={lines:n.line-t.loc.start.line,columns:n.column-t.loc.start.column};if(i.lines!==0||i.columns!==0){W(t,i);let n=u(t)?a(t.item)?t.item.value:t.item:t;(d(n)||l(n))&&n.loc.end.line>n.loc.start.line&&e(n,u(t)?t:void 0)}let s=t.loc.end.line,c=u(t)?t.item:t;o(c)&&c.loc.end.line>c.loc.start.line&&(s=c.loc.start.line+c.raw.split(` +`).length-1,c.loc.end.line=s,t.loc.end.line=s),n={line:s,column:t.loc.end.column+2}}if(n){let e=w(T.loc.end);T.loc.end={line:n.line,column:n.column-1};let r=Ji(t,T);r&&(r.loc.end={line:T.loc.end.line,column:T.loc.end.column});let i={lines:T.loc.end.line-e.line,columns:T.loc.end.column-e.column};(i.lines!==0||i.columns!==0)&&Bn(t,r??T,i)}}return}}let c=n,f=c.items,m=f[0],h=f[f.length-1],g=m.loc.start.column-c.loc.start.column-1,_=c.loc.end.column-1-h.loc.end.column;H(t,n,r),V(t,n,r,i),U(t);let v=c.items,y=v[0],b=y.loc.start.column-c.loc.start.column-1;b!==g&&W(y,{lines:0,columns:g-b});let x=v[v.length-1];c.loc.end.column-1-x.loc.end.column!==_&&(c.loc.end.column=x.loc.end.column+1+_)}function $i(e){let n=[];for(let i of e){if(i.kind===`pinned`){n.push({kind:`pinned`,isSection:!1,movable:!1,groups:[i],startLine:i.startLine,endLine:i.endLine,items:i.items});continue}let e=t(i.member)||r(i.member),a=n[n.length-1];if(a&&a.kind===`member`&&i.key!==void 0&&a.key===i.key){a.groups.push(i),a.endLine=i.endLine,a.items=a.items.concat(i.items);continue}n.push({kind:`member`,key:i.key,isSection:e,movable:i.key!==void 0,groups:[i],startLine:i.startLine,endLine:i.endLine,items:[...i.items]})}let i=new Map;for(let e of n)e.kind===`member`&&e.key!==void 0&&i.set(e.key,(i.get(e.key)??0)+1);for(let e of n)e.kind===`member`&&e.key!==void 0&&i.get(e.key)>1&&(e.movable=!1);return n}function ea(e,t){let n=[...e];for(let e of t){if(e.key===void 0)continue;let t=n.indexOf(e.key);if(t===-1)continue;let r=Math.max(0,Math.min(e.to,n.length-1));t!==r&&(n.splice(t,1),n.splice(r,0,e.key))}return n}function ta(e){return[...e.path,e.key].join(`.`)}function na(e,n,i,o){if(n.some(e=>typeof e!=`string`))return;let s=[...n,i],c=[{container:e,prefix:[]}];for(let n of e.items)(t(n)||r(n))&&c.push({container:n,prefix:n.key.item.value});for(let e of c)for(let t of e.container.items){if(!a(t)||t.key.value.length<2||o.has(t))continue;let n=[...e.prefix,...t.key.value];if(n.length===s.length&&n.every((e,t)=>e===s[t]))return{container:e.container,dottedPrefix:t.key.value.slice(0,-1)}}}function ra(n,i,o,s){if(i.length===0)return{container:n};let c;try{c=Z(n,i)}catch{return}if(!c)return;if(a(c)&&(c=c.value),c&&m(c)&&(c=c.item),c&&(e(c)||t(c)||r(c)))return{container:c};if(o===void 0)return;let l=na(n,i,o,s);if(l)return l;let u=-1;for(let e=i.length-1;e>=0;e--)if(typeof i[e]==`number`){u=e;break}if(u<0)return;let d=i.slice(u+1);if(d.length===0||d.some(e=>typeof e!=`string`))return;let f=Z(n,i.slice(0,u+1));if(!f||!r(f))return;let p=[...d,o];return f.items.some(e=>a(e)&&e.key.value.length===p.length&&e.key.value.every((e,t)=>e===p[t]))?{container:f,dottedPrefix:d}:void 0}function ia(e,t,n){if(t.key===void 0)return!1;let r;try{r=Z(e,t.path.concat(t.key))}catch{return!1}return r!==void 0&&u(r)&&!n.has(r)}function aa(t,n,r,i,o){let s=$i(Ki(t,e=>r.has(e),o?e=>{if(!a(e))return;let t=e.key.value;if(t.length===o.length+1){for(let e=0;ee.kind===`member`&&e.key!==void 0).map(e=>e.key));for(let e of n)e.key!==void 0&&l.has(e.key)&&!c.has(e.key)&&i.push(`"${ta(e)}" -- its entries are not contiguous in the document (e.g. the TOML spec's own "valid but discouraged" out-of-order table pattern), so reordering was skipped for it`);if(c.size===0)return;let u=o?s.filter(e=>e.kind===`member`&&e.movable).map(e=>e.key):s.filter(e=>e.kind===`member`).map((e,t)=>e.movable?e.key:`\u0000anchor:${t}`),d=n.filter(e=>e.key!==void 0&&c.has(e.key));if(d.length===0)return;let f=ea(u,d).filter(e=>c.has(e)),p=f.filter(e=>!c.get(e).isSection),m=f.filter(e=>c.get(e).isSection),h=!1;for(let e of f)if(c.get(e).isSection)h=!0;else if(h){i.push(`the requested order asked for a root key-value to be positioned after a section header, which TOML cannot represent -- each group (root key-values, section blocks) was reordered to match its own requested relative order instead`);break}let g=0,_=0,v=s.map(e=>{if(e.kind!==`member`||!e.movable)return e;let t=e.isSection?m[_++]:p[g++];return c.get(t)});if(v.every((e,t)=>e===s[t]))return;let y=e(t)?1:t.key.loc.end.line,b=s.map((e,t)=>t===0?e.startLine-y:e.startLine-s[t-1].endLine-1),x=y;for(let e=0;ee.items),C=t.items;C.splice(0,C.length,...S),$n(t)}function oa(e,t,n){if(t.length===0)return;Xi(e);let r=[],i=new Map;for(let o of t){var a;if(o.key===void 0)continue;let t=ra(e,o.path,o.key,n);if(!t){if(ia(e,o,n))continue;r.push(`"${ta(o)}" -- unsupported location for reordering (e.g. a dotted-key implicit table, or an interior of an inline table/array-of-tables entry)`);continue}let s=((a=t.dottedPrefix)==null?void 0:a.join(`\0`))??``,c=i.get(t.container);c||(c=new Map,i.set(t.container,c));let l=c.get(s);l?l.moves.push(o):c.set(s,{...t,moves:[o]})}for(let e of i.values())for(let t of e.values())aa(t.container,t.moves,n,r,t.dottedPrefix);if(r.length>0){let e=[...new Set(r)];console.warn(`toml-patch: updateOrder could not honor the requested position for ${e.length===1?`1 entry`:`${e.length} entries`}, left unchanged:\n`+e.map(e=>` - ${e}`).join(` +`))}$n(e)}const sa=new WeakMap,ca=new WeakMap;function la(e,t){let n=e[t].loc.end.line;for(let r=t+1;rn)return}}function ua(e,t,n){for(let r=t+n;r>=0&&r0,a=n{(e>n||e===n&&t>i)&&(n=e,i=t)};(t(e)||r(e))&&a(e.key.loc.end.line,e.key.loc.end.column);for(let t of e.items)a(t.loc.end.line,t.loc.end.column);e.loc.end.line=n,e.loc.end.column=i}function ba(e,t,n){let r=n.newLine,i=t.match(/(?:\r\n|\n)+$/u),a=i?i[0]:``,o=a?t.slice(0,-a.length):t,s=o.length?o.split(r):[],c=va(e),l=(e,t)=>{let n=e.lastIndexOf(t.raw);return n>=0?n:t.loc.start.column},u=_a(e).filter(e=>{let t=s[e.loc.start.line-1]||``,n=l(t,e),r=t.slice(0,n);return r.trim().length>0&&/\s{2,}$/u.test(r)}).sort((e,t)=>e.loc.start.line-t.loc.start.line),d=[],f=[];for(let e of u){let t=f[f.length-1];!t||e.loc.start.line===t.loc.start.line+1?f.push(e):(f.length>0&&d.push(f),f=[e])}f.length>0&&d.push(f);for(let e of d){if(e.length<2)continue;let t=ca.get(e[0])??e[0].loc.start.column,n=new Map;for(let t of e){let e=ca.get(t)??t.loc.start.column;n.set(e,(n.get(e)||0)+1)}let r=1;for(let[e,i]of n)i>r&&(r=i,t=e);let i=Math.max(...e.map(e=>{let t=s[e.loc.start.line-1]||``,n=l(t,e);return t.slice(0,n).trimEnd().length})),a=Math.min(...e.map(e=>{let t=s[e.loc.start.line-1]||``,n=l(t,e),r=t.slice(0,n).trimEnd();return Math.max(1,n-r.length)})),o=e.filter(e=>sa.has(e)),u=e.some(e=>!c.has(e.loc.start.line));if(o.length>0&&u){let t=Math.min(...e.map(e=>{if(sa.has(e))return sa.get(e)||1;let t=s[e.loc.start.line-1]||``,n=l(t,e),r=t.slice(0,n).trimEnd();return Math.max(1,n-r.length)}));a=Math.min(a,t)}if(to.leadingBom?`${e}`:e)(Da(a,t,o,n instanceof R||n!=null&&Object.prototype.hasOwnProperty.call(n,`indentWidth`),n instanceof R||n!=null&&Object.prototype.hasOwnProperty.call(n,`trailingComma`)).tomlString)}function Sa(e,t,n){let r=e.slice(n),i=0,a=Math.min(r.length,t.length);for(let e=1;e<=a;e++)I(r.slice(r.length-e),t.slice(0,e))&&(i=e);return i}function Ca(e,t,n){if(n===void 0)return;let r=t.key.value;if(r.length<2)return;let i=r.slice(0,-1);for(let t=0;t{t.add(e)};return Dt(e,{Document:n,Table:n,TableKey:n,TableArray:n,TableArrayKey:n,KeyValue:n,Key:n,String:n,Integer:n,Float:n,Boolean:n,DateTime:n,InlineArray:n,InlineItem:n,InlineTable:n,Comment:n}),t}function Ta(e,t,n,r){if(!F(t))return t;let i=J(q(e,L({...n,inlineTableStart:0},n)).items,``,{temporal:r});if(!F(i))return t;let a={};for(let e of Object.keys(i))Object.prototype.hasOwnProperty.call(t,e)&&(a[e]=t[e]);for(let e of Object.keys(t))Object.prototype.hasOwnProperty.call(a,e)||(a[e]=t[e]);return a}function Ea(e){let n=e.items;for(let e=0;e0&&f(a[o-1]);)o--;if(os||t.line===s&&t.column>c)&&(s=t.line,c=t.column)}let l=J(a,``,{temporal:o}),u={type:`Document`,loc:{start:{line:1,column:0},end:{line:s,column:c}},items:a};Mn(u,n.indentWidth);let d=n.inlineTableStart!=null&&n.inlineTableStart>1?1:n.inlineTableStart,f=L({...n,inlineTableStart:d},n),p=q(t,f),m=J(p.items,``,{temporal:o}),h=n.updateOrder?Ta(t,m,f,o):m;if(!n.updateOrder&&Oa(l,h))return{tomlString:ai(a,n),document:u};let g=ja(Ma(u,h,Ei(l,h,[],{updateOrder:n.updateOrder,orderSource:t})));if(g.length===0)return{tomlString:ai(a,n),document:u};let _=za(u,p,g,n,o,wa(u),t,r,i);return{tomlString:ba(_,oi(_.items,n),n),document:_}}function Oa(e,t){let n=[[e,t]];for(;n.length>0;){let[e,t]=n.pop();if(typeof e==`number`&&typeof t==`number`&&(!Number.isFinite(e)||!Number.isFinite(t))||typeof e==`number`&&typeof t==`number`&&Number.isNaN(e)&&Number.isNaN(t))return!1;if(Object.is(e,t))continue;if(e instanceof Date&&t instanceof Date){if(e.getTime()!==t.getTime())return!1;continue}if(!e||!t||typeof e!=`object`||typeof t!=`object`||Array.isArray(e)!==Array.isArray(t))return!1;if(!Array.isArray(e)){let n=Object.getPrototypeOf(e),r=Object.getPrototypeOf(t);if(n!==Object.prototype&&n!==null||r!==Object.prototype&&r!==null)return!1}let r=Object.keys(e),i=Object.keys(t);if(r.length!==i.length)return!1;for(let i of r){if(!Object.prototype.hasOwnProperty.call(t,i))return!1;n.push([e[i],t[i]])}}return!0}function ka(e,t=new WeakSet){return typeof e!=`object`||!e?!1:pt(e)?!0:!t.has(e)&&(t.add(e),Object.values(e).some(e=>ka(e,t)))}function Aa(e){if(Array.isArray(e)){let t=!1,n=[];for(let r=0;rr){e.splice(a,1),e.splice(t,0,o),t=-1;break}a++}}}return e}function Ma(e,t,n){let i=new Set,o=[],s=new Map;for(let e of n){if(!Y(e)&&!Si(e))continue;let t=e.path.slice(0,-1);if(t.length===0)continue;let n=JSON.stringify(t),r=s.get(n);r||(r={path:t,removes:[],adds:[]},s.set(n,r)),(Y(e)?r.removes:r.adds).push(e)}for(let t of s.values())t.removes.length!==0&&t.adds.length!==0&&(Z(e,t.path)||(t.removes.forEach(e=>i.add(e)),t.adds.forEach(e=>i.add(e)),o.push({type:`Edit`,path:t.path})));for(let t of n){if(i.has(t)||!Y(t)&&!Si(t))continue;let r=t.path.slice(0,-1);if(r.length===0||typeof P(r)!=`number`)continue;let s=Z(e,r);if(!s||!u(s)||!d(s.item))continue;let c=n.filter(e=>!i.has(e)&&(Y(e)||Si(e))&&I(e.path.slice(0,-1),r));if(c.length===0||c.some(e=>e.path.length!==r.length+1))continue;let l=new Set(s.item.items.filter(u).map(e=>{let t=a(e.item)?e.item:void 0;return(t==null?void 0:t.key.value.length)===1?t.key.value[0]:void 0}).filter(e=>e!==void 0)),f=new Set(c.filter(Y).map(e=>P(e.path)));if(c.some(Y)&&c.some(Si)&&l.size===f.size&&![...l].some(e=>!f.has(e))){for(let e of c)i.add(e);o.push({type:`Edit`,path:r})}}let c=new Map;for(let t of n){if(i.has(t)||!Ci(t)&&!Si(t)&&!Y(t)||typeof P(t.path)!=`number`)continue;let n=t.path.slice(0,-1);if(n.length===0)continue;let a=Z(e,n.concat(0));a&&r(a)&&c.set(JSON.stringify(n),n)}for(let e of c.values()){let r=t;for(let t of e)r=r==null?void 0:r[t];if(!Array.isArray(r)||r.every(e=>F(e)))continue;let a=n.filter(t=>!i.has(t)&&(Ci(t)||Si(t)||Y(t))&&t.path.length>e.length&&I(t.path.slice(0,e.length),e));if(!(a.length<2)){for(let e of a)i.add(e);o.push({type:`Edit`,path:e})}}if(i.size===0)return n;let l=n.filter(e=>!i.has(e));return l.push(...o),l}function Na(e,t){return t.map(t=>k.test(t)?t:`"${dr(t,e,`singleline-basic`)}"`).join(`.`)}function Pa(e){let t=[],n,r=!1;for(let i=0;i0&&(e[o-1]===` `||e[o-1]===` `);)o--;let s=i+1;for(;sr===0?t:`${e}${n[Math.min(r-1,n.length-1)]}${t}`,``),e.loc.end.column=e.loc.start.column+e.raw.length)}function La(e,t){let n;return Dt(e,{KeyValue:e=>{!n&&e.key.value.length>t.length&&I(e.key.value.slice(0,t.length),t)&&Pa(e.key.raw).length>0&&(n=e)}}),n}function Ra(e,t){if(o(e)&&o(t)){let n=br(t.value,e.raw);t.raw=n.raw,t.loc=n.loc}if(c(e)&&c(t)){let n=e.raw,r=t.value;if(pt(r)){let e=mt(r);if(n.includes(` `)&&e.includes(`T`)&&(e=e.replace(`T`,` `)),/(?:\+00:00|-00:00)/.test(n)&&e.endsWith(`Z`)){let t=n.match(/([+-]00:00)/)[1];e=e.replace(/Z$/,t)}t.raw=e,t.loc.end.column=t.loc.start.column+t.raw.length}else if(!(he.IS_TIME_ONLY.test(n)&&r instanceof Date&&r.getUTCFullYear()>1)){let e=he.createDateWithOriginalFormat(r,n);t.value=e,t.raw=e.toISOString(),t.loc.end.column=t.loc.start.column+t.raw.length}}if(s(e)&&s(t)&&Number.isNaN(e.value)&&Number.isNaN(t.value)){let n=e,r=t;n.nanSign&&(r.nanSign=`+`,r.raw=`+nan`)}if(l(e)&&l(t)){let n=Pr(e);if(t.items.length>0){let e=t.items[t.items.length-1];e.comma!==n&&(e.comma=n,n&&(t.loc.end.column+=1))}}if(d(e)&&d(t)){let n=Fr(e);if(t.items.length>0){let e=t.items[t.items.length-1];e.comma!==n&&(e.comma=n,n&&(t.loc.end.column+=1))}}}function za(n,i,s,c,h=!1,g=new WeakSet,_=void 0,v=!1,y=!0){let x=new Map,S=new Set;function C(e){let t=n.items,r=t.indexOf(e);if(r>0){let n=t[r-1];f(n)&&n.loc.end.line+1===e.loc.start.line&&S.add(e)}}let w=new WeakMap;function T(e){let i=w.get(e);if(i!==void 0)return i;let o={};function s(n,i){if(n===e)return i;if(a(n))return s(n.value,i);if(u(n))return s(n.item,i);if(p(n))for(let e=0;et.length||!I(n.key.value,t.slice(t.length-r))}function D(e,i){let o=e.key.item.value.concat(i);return e.items.some(e=>a(e)&&e.key.value.length>i.length&&I(e.key.value.slice(0,i.length),i))?!0:n.items.some(e=>{if(!t(e)&&!r(e))return!1;let n=e.key.item.value;return n.length>o.length&&I(n.slice(0,o.length),o)})}function O(e,t){let n=[],r=!1;for(let i of e.items){if(!a(i)||!d(i.value)||!D(t,i.key.value)){n.push(i);continue}let e=[];for(let t of i.value.items){if(!u(t)||!a(t.item))continue;let n=J([t.item],``,{temporal:h});for(let e of t.item.key.value)n=n==null?void 0:n[e];let r=eo(n,c);r!==void 0&&e.push(gr(i.key.value.concat(t.item.key.value),r))}e.length>0?(n.push(...e),r=!0):n.push(i)}if(!r)return e;let i=mr(e.key.item.value);for(let e of n)V(i,i,e);return U(i),i}function ee(e,t){function n(e){if(a(e))return n(e.value);if(u(e))return n(e.item);if(!p(e))return;let r=e.items;for(let n of r)if(n===t||u(n)&&n.item===t)return e;for(let e of r){let t=n(u(e)?e.item:e);if(t)return t}}return n(e)}function A(e,t,r,i){let a=t.concat(e.key.value),o=e.key.raw;e.key.value=a,e.key.raw=a.map(e=>k.test(e)?e:JSON.stringify(e).replace(/\x7f/g,`\\u007f`)).join(`.`);let s=e.key.raw.length-o.length;e.key.loc.end.column=e.key.loc.start.column+e.key.raw.length,e.equals+=s,W(e.value,{lines:0,columns:s},{first_line_only:!0}),e.loc.end.line===e.loc.start.line&&(e.loc.end.column+=s),Kn(n,i,r,e),Ua(n,e)}function te(e,t){let n=e.key.item,r=n.raw,i=t.concat(n.value),a=i.map(e=>k.test(e)?e:JSON.stringify(e).replace(/\x7f/g,`\\u007f`)).join(`.`),o=a.length-r.length;n.value=i,n.raw=a,n.loc.end.column=n.loc.start.column+a.length,e.key.loc.end.line===e.key.loc.start.line&&(e.key.loc.end.column+=o)}function ne(e,t,r,i){te(e,t),Kn(n,i,r,e)}function j(e,t,r){let i=Z(n,e);if(i&&!E(i,e))return null;let a=e.length;for(;a>0;){let t=Z(n,e.slice(0,a));if(t&&!E(t,e.slice(0,a)))break;a--}let o=e.slice(a);if(o.length===0||!o.every(e=>typeof e==`string`))return null;if(_!==void 0){let e=_;for(let t of r)e=e==null?void 0:e[t];if(e!==void 0){let n=eo(e,c);if(n!==void 0)return gr([...o,...t.key.value],n)}}let s=t.key,l=[...o,...s.value].map(e=>k.test(e)?e:JSON.stringify(e).replace(/\x7f/g,`\\u007f`)).join(`.`),u=l.length-s.raw.length;return s.raw=l,s.value=[...o,...s.value],s.loc.end.column=s.loc.start.column+l.length,t.equals+=u,W(t.value,{lines:0,columns:u},{first_line_only:!0}),t.loc.end.line===t.loc.start.line&&(t.loc.end.column+=u),t}let M=new Set,re=new Set,ie=new WeakMap;function ae(e,t){return ie.get(e)??t}let oe=[],se=new Set,ce=[];function le(e,t,r){if(!l(e)&&!d(e)||!u(r)||d(e)&&!a(r.item))return r;let i=_;for(let e of t)i=i==null?void 0:i[e];if(!Array.isArray(i)&&!F(i))return r;let o=En(n,e)??0,s=nr(e)||e.items.length===0&&e.loc.end.line>e.loc.start.line,f=v?c.indentWidth:l(e)&&e.items.length===0?Math.max(c.indentWidth,4):c.indentWidth;l(e)&&e.items.length===0&&!c.useTabsForIndentation&&Nn(e,Math.max(f,4));let p=Array.isArray(i)?`array`:`table`,m=e.items.map(t=>d(e)&&a(t.item)?t.item.value:t.item).find(e=>p===`array`?l(e):d(e)),h=m&&p===`array`?Pr(m):m&&p===`table`?Fr(m):!1,g=m,b=g&&g.items.length>0?g.items[0].loc.start.column-g.loc.start.column>1:void 0,x=mn(p,o+1,s,c),S=p===`array`?`multilineArray`:`multilineTable`,C=L({...c,inlineTableStart:0,[S]:x,trailingComma:y?c.trailingComma:h,bracketSpacing:b??c.bracketSpacing,indentWidth:f},c),w=q({tmp:i},C,o+1,s).items[0];if(!w||!a(w))return r;let T=w.value;if(x&&(l(T)||d(T))){let e=T.loc.start.column;T.loc.start.column=0,T.loc.end.line===T.loc.start.line?T.loc.end.column-=e:mn(l(T)?`array`:`table`,o+1,s,C)&&(T.loc.end.column=1)}if(d(e)){let e=K(gr(r.item.key.value,T));return e.comma=r.comma,e}let E=K(T);return e.items.length===0&&l(e)&&d(T)&&(E.comma=!0),E}function ue(e,t,r){let i=u(t)?t.item:t;if(!l(i)&&!d(i))return t;let o=_;for(let e of r)o=o==null?void 0:o[e];if(!Array.isArray(o)&&!F(o))return t;let s=Dn(n,e),f=s?En(n,s)??0:-1,p=s?nr(s)||s.items.length===0&&s.loc.end.line>s.loc.start.line:!1,m=Array.isArray(o)?`array`:`table`,h=m===`array`?c.multilineArray:c.multilineTable,g=mn(m,f+1,p,c),v=u(e)?e.item:e,b=l(v)||d(v)?v:void 0,x=l(v)?`array`:d(v)?`table`:void 0,S=b!==void 0&&x!==m&&Tn(b)&&h===`auto`;if(!(g||S))return t;let C=m===`array`?`multilineArray`:`multilineTable`,w=L({...c,inlineTableStart:0,[C]:!0,trailingComma:y?c.trailingComma:!1},c),T=q({tmp:o},w,f+1,p).items[0];if(!T||!a(T))return t;let E=T.value;if(l(E)||d(E)){let t=u(e)?e.item.loc.start.column:e.loc.start.column;E.loc.end.line===E.loc.start.line?E.loc.end.column+=t-E.loc.start.column:E.loc.end.column=1,E.loc.start.column=t}if(u(t)){let e=K(E);return e.comma=t.comma,e}return E}s.forEach(s=>{if(Si(s)){var v;let o=X(i,s.path),m=s.path.slice(0,-1),g=P(s.path),_=r(o);if(st(g)&&!_&&!st(P(m))){let e=Z(n,m.concat(0));e&&r(e)&&(_=!0)}if(_&&st(g)&&!st(P(m))){let e=Z(n,m),t=e&&a(e)?e.value:e;if(t&&l(t)){let e=J(i.items,``,{temporal:h});for(let t of s.path)e=e==null?void 0:e[t];let r=L({...c,inlineTableStart:0},c),l=e===void 0?void 0:q({tmp:e},r),f=l==null?void 0:l.items[0];if(f&&a(f)&&(o=K(f.value),_=!1,u(o)&&d(o.item)&&o.item.items.length>0)){let e=o.item;if(!(e.items[0].loc.start.column-e.loc.start.column>1)){let r=t.items.map(e=>e.item).find(e=>d(e)&&e.items.length>0);r&&r.items[0].loc.start.column-r.loc.start.column>1&&er(n,e,!0)}}}}if(_&&!r(o)){let e=m.filter(e=>typeof e==`string`),n=J(i.items,``,{temporal:h});for(let e of s.path)n=n==null?void 0:n[e];if(n!==void 0){let i=mr(e),a=q(n,c);for(let e of a.items)V(i,i,e,void 0);let s=n=>{if(p(n))for(let i of n.items){if(!t(i)&&!r(i))continue;let n=i.key,a=n.item,o=e.concat(a.value);a.value=o,a.raw=yr(o).raw,a.loc.start.column=n.loc.start.column+1,a.loc.end.column=a.loc.start.column+a.raw.length,n.loc.end.column=a.loc.start.column+a.raw.length+1,s(i)}};s(i),U(i),o=i}}let y;if(t(o))y=n;else if(_){y=n;let e=n,i=Z(e,m.concat(g-1)),a=Z(e,m.concat(g));if(a)g=e.items.indexOf(a);else if(i){if(g=e.items.indexOf(i)+1,r(i)){let n=i.key.item.value,a=g-1;for(let i=g;in.length&&I(s.slice(0,n.length),n)&&(a=i)}g=a+1,r(o)&&(o=O(o,i))}}else g=e.items.length}else{y=n;for(let e=s.path.length-1;e>=0;e--){let t=Z(n,s.path.slice(0,e));if(t&&!E(t,s.path.slice(0,e))){y=t;break}}a(y)?y=y.value:u(y)&&a(y.item)?y=y.item.value:(u(y)&&d(y.item)||u(y)&&l(y.item))&&(y=y.item)}if((l(y)||d(y))&&(o=le(y,s.path,o),Zr(y,o,c.indentWidth)),l(y)){let e=Z(n,m),t=Z(n,m.slice(0,-1));if(e&&a(e)&&t){let n=ga(y,o,g);n!==0&&ma(t,e,n)}}let b=(l(y)||d(y))&&y.loc.end.line>y.loc.start.line;b&&re.has(y)&&U(n);let x=l(y)||d(y)?(v=Yi(n,y))==null?void 0:v.items:void 0;if(r(y)||l(y)||e(y)){if(l(y)){let e=ae(y,Pr(y));if(u(o)){let t=y.items.length===0&&d(o.item)&&nr(o.item);o.comma=t||e;let n=o,r=d(n.item)?n.item:void 0,l=r&&r.items.length>0?y.items.map(e=>e.item).find(e=>e!==n.item&&d(e)):void 0;if(r&&l){let e=Fr(l);if(e!==Fr(r)){let t=J(i.items,``,{temporal:h});for(let e of s.path)t=t==null?void 0:t[e];let r=L({...c,inlineTableStart:0,trailingComma:e},c),l=t===void 0?void 0:q({tmp:t},r).items[0];if(l&&a(l)){let e=K(l.value);e.comma=n.comma,o=e}}}}}if(c.inlineTableStart!==void 0&&c.inlineTableStart>0&&e(y)&&t(o)){let e=Qa(o,n,c);V(n,y,o,g);for(let t of e)V(n,n,t,void 0)}else{let i=o;u(o)&&(r(y)||e(y))&&(i=o.item);let c=g;if(e(y)&&a(i)){let e=y.items.findIndex(e=>t(e)||r(e));e!==-1&&(c=e)}let d=!1;if((r(y)||e(y))&&a(i)){let e=j(m,i,s.path);e&&(M.has(y)&&U(n),i=e,d=!0)}if(l(y)&&y.items.length===0&&U(n),c===0&&l(y)&&Wn(n).has(y)&&U(n),c===0&&(e(y)||t(y)||r(y))&&Wn(n).has(y)&&U(n),a(i)&&i.key.value.length>1){let e=La(n,i.key.value.slice(0,-1));if(e){let t=i.equals;Ia(i.key,e.key.raw),i.equals=i.key.loc.end.column+e.equals-e.key.loc.end.column;let n=i.equals-t;W(i.value,{lines:0,columns:n},{first_line_only:!0}),i.loc.end.line===i.loc.start.line&&(i.loc.end.column+=n)}}V(n,y,i,c,void 0,x),d&&M.add(y)}}else if(d(y)){y.items.length===0&&(U(n),Rn(y)&&y.loc.end.line>y.loc.start.line&&(y.loc.end.line=y.loc.start.line));let e=ae(y,Fr(y));if(a(o)){let t=K(o);t.comma=e;let r=Ca(y,o,s.before);V(n,y,t,r,void 0,x)}else if(u(o)&&a(o.item)){let t=o.item,r=j(m,t,s.path);r&&(M.has(y)&&U(n),t=r);let i=K(t);i.comma=e;let a=Ca(y,t,s.before);V(n,y,i,a,void 0,x),r&&M.add(y)}else V(n,y,o,void 0,void 0,x)}else if(c.inlineTableStart!==void 0&&c.inlineTableStart>0&&a(o)&&d(o.value)&&t(y))Gr([...y.key.item.value,...o.key.value])0&&y.items.every(f)?2:void 0;V(n,y,i,void 0,void 0,void 0,l),c&&M.add(y)}b&&re.add(y)}else if(Ci(s)){let f=Z(n,s.path),m;try{m=X(i,s.path)}catch{m=void 0}if(!f||!m){Ga(n,i,s,c,h,g,S);return}if(a(f)&&t(m)){let e=m.key.item.value;if($(n,e).some(e=>e!==f)){Ga(n,i,s,c,h,g,S);return}}if(u(f)&&(t(m)||r(m))){let e=Z(n,s.path.slice(0,-1)),t=e&&a(e)?e.value:e;if(t&&l(t)){let e=_;for(let t of s.path)e=e==null?void 0:e[t];if(e!==void 0){let t=L({...c,inlineTableStart:0},c),n=q({tmp:e},t),r=n==null?void 0:n.items[0];r&&a(r)&&(m=K(r.value))}}}let v,y=Z(n,s.path.slice(0,-1));y&&a(y)&&E(y,s.path.slice(0,-1))&&(y=ee(n,y));let x=to(n,s.path);if(a(f)&&a(m)){let i=!1;if(f.key.value.length>1){let o=T(f),c=Sa(s.path,f.key.value,o?o.length-f.key.value.length:0);if(c>0&&c=0;i--){let o=e[i];if(o===f)continue;let s=a(o)?o.key.value:t(o)||r(o)?o.key.item.value:void 0;s&&s.length>=u.length&&I(s.slice(0,u.length),u)&&Q(n,y,o)}}}}if(!i&&a(m)&&y&&(t(y)||e(y)||r(y))&&ro(n,y,f.key.value,f),Ra(f.value,m.value),y&&pa(y,f,f.value,m.value),v=f,f=f.value,m=m.value,i&&_!==void 0){let e=_;for(let t of s.path)e=e==null?void 0:e[t];if(e!==void 0){let t=eo(e,c);t!==void 0&&(m=t,Ra(f,m))}}}else if(a(f)&&u(m)&&a(m.item)){let i=!1;if(f.key.value.length>1){let o=T(f),c=Sa(s.path,f.key.value,o?o.length-f.key.value.length:0);if(c>0&&c=0;i--){let o=e[i];if(o===f)continue;let s=a(o)?o.key.value:t(o)||r(o)?o.key.item.value:void 0;s&&s.length>=u.length&&I(s.slice(0,u.length),u)&&Q(n,y,o)}}}}if(!i&&a(m.item)&&y&&(t(y)||e(y)||r(y))&&ro(n,y,f.key.value,f),v=f,f=f.value,m=m.item.value,i&&_!==void 0){let e=_;for(let t of s.path)e=e==null?void 0:e[t];if(e!==void 0){let t=eo(e,c);t!==void 0&&(m=t)}}}else if(u(f)&&a(f.item)&&a(m)){let e=f.item,i=!1;if(e.key.value.length>1){let o=T(e),c=Sa(s.path,e.key.value,o?o.length-e.key.value.length:0);if(c>0&&c=0;i--){let o=e[i];if(o===f)continue;let s=a(o)?o.key.value:u(o)&&a(o.item)?o.item.key.value:t(o)||r(o)?o.key.item.value:void 0;s&&s.length>=d.length&&I(s.slice(0,d.length),d)&&Q(n,m,o)}}}}if(Ra(e.value,m.value),v=e,f=e.value,m=m.value,i&&_!==void 0){let e=_;for(let t of s.path)e=e==null?void 0:e[t];if(e!==void 0){let t=eo(e,c);t!==void 0&&(m=t,Ra(f,m))}}}else if(u(f)&&u(m)&&a(f.item)&&a(m.item)){let e=!1,i=f.item;if(i.key.value.length>1){let o=T(i),c=Sa(s.path,i.key.value,o?o.length-i.key.value.length:0);if(c>0&&c=0;i--){let o=e[i];if(o===f)continue;let s=a(o)?o.key.value:u(o)&&a(o.item)?o.item.key.value:t(o)||r(o)?o.key.item.value:void 0;s&&s.length>=d.length&&I(s.slice(0,d.length),d)&&Q(n,m,o)}}}}if(Ra(i.value,m.item.value),v=i,f=i.value,m=m.item.value,e&&_!==void 0){let e=_;for(let t of s.path)e=e==null?void 0:e[t];if(e!==void 0){let t=eo(e,c);t!==void 0&&(m=t,Ra(f,m))}}}else if(t(f)){let o=J(i.items,``,{temporal:h});for(let e of s.path)o=o==null?void 0:o[e];if(o!==void 0){let i=f.key.item.value,l=i.slice(-1),u=i.slice(0,-1),d=ee(n,f)??Ni(n,s.path);if(e(d)){let e=$(n,i).filter(e=>e!==f&&(t(e)||r(e)));for(let t of e)Q(n,d,t)}let p=q({[l[0]]:o},c),m=p.items[0];if(u.length>0){if(t(m)||r(m))ne(m,u,f,d),g.add(m);else if($(n,u).some(a))A(m,u,f,d),g.add(m);else{let e=n.items.find(e=>e!==f&&(t(e)||r(e))&&I(e.key.item.value,u));if(e)V(n,e,m,void 0),Q(n,d,f),g.add(m);else{let e=G(u);S.add(e),V(n,e,m,0),Kn(n,d,f,e),g.add(e)}}}else Kn(n,d,f,m),g.add(m),Ua(n,m);if(p.items.length>1)for(let e=1;e0&&te(t,u);let r=e===1?m:p.items[e-1];V(n,d,t,d.items.indexOf(r)+1),g.add(t)}return}v=Ni(n,s.path)}else if(r(f)){let o=J(i.items,``,{temporal:h}),l=f.key.item.value,u=o,d=typeof s.path[s.path.length-1]==`number`?s.path.slice(0,-1):s.path;for(let e of d)u=u==null?void 0:u[e];if(u!==void 0){let i=l.slice(-1),o=l.slice(0,-1),d=ee(n,f)??Ni(n,s.path),p=q({[i[0]]:u},c).items[0];if(Array.isArray(u)&&!u.every(e=>F(e))){let e=$(n,l).filter(e=>e!==f&&(t(e)||r(e)));for(let t of e)Q(n,n,t)}if(o.length>0){if(t(p)||r(p))ne(p,o,f,d),g.add(p);else{let r=e(d)?d.items.find(e=>t(e)&&I(e.key.item.value,o)):void 0;if(r){let e=r.items.find(e=>a(e)&&I(e.key.value,p.key.value));e?Kn(n,r,e,p):V(n,r,p,void 0),Q(n,d,f),g.add(r);return}if($(n,o).some(a))A(p,o,f,d),g.add(p);else{let e=G(o);S.add(e),V(n,e,p,0),Kn(n,d,f,e),g.add(e)}}}else Kn(n,d,f,p),g.add(p),Ua(n,p);return}v=Ni(n,s.path)}else if(v=Ni(n,s.path),a(v)){let e=X(n,s.path.slice(0,-1));a(e)&&l(e.value)&&(v=e.value)}else u(v)&&a(v.item)?v=v.item.value:(u(v)&&d(v.item)||u(v)&&l(v.item))&&(v=v.item);if(u(f)&&u(m)&&(o(f.item)&&o(m.item)&&(Ra(f.item,m.item),m.loc={start:{...m.item.loc.start},end:{...m.item.loc.end}}),m.comma=f.comma),x){let e=b(f.loc),t=b(m.loc).columns-e.columns;t!==0&&ha(x.container,x.row,t)}m=ue(f,m,s.path),Kn(n,v,f,m),l(m)&&se.add(m),(l(m)||d(m))&&(U(n),Ur(m,c.indentWidth),ce.push({owner:v,container:m})),e(v)&&a(f)&&(t(m)||r(m))&&Wa(n,m)}else if(Y(s)){let o=Z(n,s.path);if(o){let f=Ni(n,s.path);if(f===o&&(f=Ni(n,s.path.slice(0,-1))),f===o){let e=Yi(n,o);e&&(f=e)}a(f)&&(f=f.value),u(f)&&a(f.item)&&(f=f.item.value),u(f)&&d(f.item)&&(f=f.item),u(f)&&l(f.item)&&(f=f.item),(!p(f)||!f.items.includes(o))&&(f=ee(n,o)??(p(f)?n:Yi(n,o)??n));let h=!1;if(s.path.length>1&&t(o)&&_!==void 0){let e=s.path.slice(0,-1),t=o.key.item.value.slice(0,-1);if($(n,t).filter(e=>e!==o).length===0){let r=_;for(let t of e)r=r==null?void 0:r[t];if(F(r)&&Object.keys(r).length===0){let e=o;for(;e.items.length>0;)H(n,e,P(e.items));U(n);let r=e.key,i=m(r)?r.item:r;i.value=t,i.raw=Na(i.raw,i.value),i.loc.end.column=i.loc.start.column+i.raw.length,r.loc.end.column=r.loc.start.column+i.raw.length+2,e.loc.end.line=r.loc.end.line,e.loc.end.column=r.loc.end.column,C(e),h=!0}}}let g=e(f)&&p(f)?f.items.indexOf(o):-1,v=p(f)&&!e(f)?f.items.indexOf(o):-1,y=u(o)?o.comma:void 0;u(o)&&(l(f)||d(f))&&Nn(f,o.loc.start.column),u(o)&&(l(f)||d(f))&&v===f.items.length-1&&y!==void 0&&ie.set(f,y);let b=u(o)&&(l(f)||d(f))&&o.loc.end.line===f.loc.end.line?f.loc.end.column-o.loc.end.column:void 0,w=T(u(o)&&a(o.item)?o.item:o),E=a(o)?o:u(o)&&a(o.item)?o.item:void 0;if(E&&w&&p(f)){let e=w.length-E.key.value.length,i=s.path.slice(e);if(i.length>0&&i.lengthi.length&&I(e.slice(0,i.length),i)&&s.push(n)}for(let e of s)Q(n,f,e)}}if(E&&w&&p(f)){let e=E.key.value,i=f.items,s=[];for(let n of i){if(n===o)continue;let i=a(n)?n.key.value:u(n)&&a(n.item)?n.item.key.value:t(n)||r(n)?n.key.item.value:void 0;i&&i.length>e.length&&I(i.slice(0,e.length),e)&&s.push(n)}for(let e of s)Q(n,f,e)}if((t(o)||r(o))&&p(f)){let e=o.key.item.value,i=f.items,s=[];for(let n of i){if(n===o)continue;let i=a(n)?n.key.value:t(n)||r(n)?n.key.item.value:void 0;i&&i.length>e.length&&I(i.slice(0,e.length),e)&&s.push(n)}for(let e of s)Q(n,f,e)}if(h||Q(n,f,o),u(o)&&(l(f)||d(f))&&o.loc.end.line===f.loc.end.line){let e=f.items,t=f.loc.end.line===f.loc.start.line?e.every(e=>e.loc.end.line===f.loc.start.line):e.length>0&&P(e).loc.end.line===f.loc.end.line;if(e.length>0&&v===e.length&&t){let t=b??f.loc.end.column-o.loc.end.column;U(n);let r=P(e).loc.end.column+t;f.loc.end.column!==r&&(f.loc.end.column=r);let i,s=e=>{if(a(e))return e.value===f?e:s(e.value);if(u(e))return s(e.item);if(p(e))for(let t of e.items){let e=s(u(t)?t.item:t);if(e)return e}};if(i=s(n),i&&i.loc.end.line===f.loc.end.line){i.loc.end.column=f.loc.end.column;let e=t=>{if(a(t))return e(t.value);if(u(t))return t.item===i?t:e(t.item);if(p(t))for(let n of t.items){let t=e(n);if(t)return t}},t=e(n);if(t){let e=f.loc.end.column-t.loc.end.column;t.loc.end.column=f.loc.end.column,e!==0&&(Bn(n,t,{lines:0,columns:e}),Vn(n),U(n))}}}}if(!h&&s.path.length>1&&(t(o)||r(o))&&_!==void 0){let e=s.path.slice(0,-1),t=o.key.item.value.slice(0,-1);if($(n,t).length===0){let r=_;for(let t of e)r=r==null?void 0:r[t];if(F(r)&&Object.keys(r).length===0){let e=G(t);S.add(e);let r=g>=0?Math.min(g,n.items.length):n.items.length;U(n),V(n,n,e,r)}}}if(!h&&s.path.length>1&&_!==void 0){let i=a(o)?o:u(o)&&a(o.item)?o.item:void 0;if(i&&i.key.value.length>1){let o=s.path.slice(0,-1),l=e(f)||t(f)||r(f)||d(f)?f:n;if(p(l)){let s;if(t(l)||r(l)){let e=l.key.item.value,t=0,n=0;for(let r=0;r=o.length||o[t]!==e[r])break;n++,t++}for(;t=o.length||o[t]!==e[n])break;n++,t++}for(;ttypeof e==`string`);let f=l.items.filter(e=>{let t=a(e)?e.key.value:u(e)&&a(e.item)?e.item.key.value:void 0;return t?I(t.slice(0,s.length),s):!1}),p=r(l),m=[];p&&f.length===0&&(m=$(n,l.key.item.value.concat(s)));let h=s.length===0&&(t(l)||r(l)||d(l));if(f.length===0&&m.length===0&&!h){let r=_;for(let e of o)r=r==null?void 0:r[e];if(F(r)&&Object.keys(r).length===0){if(d(l)){let e=s.map(e=>k.test(e)?e:JSON.stringify(e).replace(/\x7f/g,`\\u007f`)).join(`.`),t=Array.from(Be(`${e} = {}`))[0];if(a(t)){let e=K(t);e.comma=y??!1,U(n),Rn(l)&&l.loc.end.line>l.loc.start.line&&(l.loc.end.line=l.loc.start.line),V(n,l,e,v>=0?v:void 0),c.bracketSpacing&&W(e,{lines:0,columns:1});let r=l.items[v+1],i=B(n).get(e);r&&i&&r.loc.start.line===e.loc.end.line&&(i.columns=e.loc.end.column+2-r.loc.start.column)}return}let r=G(p||t(l)?l.key.item.value.concat(s):s);S.add(r);let i=g>=0?g:n.items.length;if(p){let e=n.items.indexOf(l);i=e>=0?e+1:n.items.length}else if(t(l)){let e=n.items.indexOf(l);i=e>=0?e+1:n.items.length}else if(e(l)){let e=Va(n);i=e>=0?e:n.items.length}U(n),V(n,n,r,i)}}}}}if(r(o)){let e=o.key.item.value,t=s.path.slice(0,-1);!Z(n,t.concat(0))&&Z(i,t)&&x.set(e.join(`.`),e)}}else{let e=()=>Z(n,s.path.concat(0))||$(n,s.path).filter(r)[0],a=e();if(a){let o=n.items.indexOf(a),c=!1;if(s.path.length>1&&_!==void 0){let e=s.path.slice(0,-1);if($(n,e).filter(e=>!(r(e)&&I(e.key.item.value,s.path))).length===0){let t=_;for(let n of e)t=t==null?void 0:t[n];if(F(t)&&Object.keys(t).length===0){let t=a,r=t.key;for(;t.items.length>0;)Q(n,t,P(t.items));U(n),t.type=`Table`,r.type=`TableKey`;let i=m(r)?r.item:r;i.value=e,i.raw=Na(i.raw,i.value),i.loc.end.column=i.loc.start.column+i.raw.length,r.loc.end.column=r.loc.start.column+i.raw.length+2,t.loc.end.line=r.loc.end.line,t.loc.end.column=r.loc.end.column,C(t),c=!0}}}let l;for(;l=e();)Q(n,n,l);for(let e of $(n,s.path).filter(t))Q(n,n,e);if(Z(i,s.path)){let e=s.path;x.set(e.join(`.`),e)}if(!c&&s.path.length>1&&_!==void 0){let e=s.path.slice(0,-1);if($(n,e).length===0){let t=_;for(let n of e)t=t==null?void 0:t[n];if(F(t)&&Object.keys(t).length===0){let t=G(e);S.add(t);let r=o>=0?Math.min(o,n.items.length):n.items.length;U(n),V(n,n,t,r)}}}}else{let e=no(n,s.path),t=$(n,e);if(t.length>0){let r=n.items.indexOf(t[0]);for(let e of t)Q(n,n,e);if(e.length>1&&_!==void 0){let t=e.slice(0,-1);if($(n,t).length===0){let e=_;for(let t of s.path.slice(0,-1))e=e==null?void 0:e[t];if(F(e)&&Object.keys(e).length===0){let e=G(t);S.add(e);let i=r>=0?Math.min(r,n.items.length):n.items.length;U(n),V(n,n,e,i)}}}}else X(n,s.path)}}}else if(wi(s)){if(s.key!==void 0){oe.push(s);return}let e=Z(n,s.path);if(e){if(m(e)&&(e=e.item),a(e)&&(e=e.value),s.from>=e.items.length)return;let t=e.items[s.from];Qi(n,e,t,s.to)}else{Ea(n);let e=X(n,s.path.concat(s.from)),i=Ki(n).find(t=>t.member===e),a=i?[...i.items]:[e],o=a.map(e=>({startLine:e.loc.start.line,endLine:e.loc.end.line})),c=n.items.indexOf(i?i.items[0]:e),l=o[o.length-1].endLine,u;for(let e=0;el){u=t.loc.start.line-l;break}}if(u===void 0&&c>0){let e=n.items[c-1];u=o[0].startLine-e.loc.end.line}let d=i?i.items.indexOf(e):0;for(let e=0;e<=d;e++)n.items.includes(a[e])&&H(n,n,a[e]);for(let e=d+1;e=0&&n.items.splice(t,1)}let p=Z(n,s.path.concat(s.to)),m,h=!1,g=i&&i.items[0]!==e&&f(i.items[0]);if(p){let e=Ki(n).find(e=>e.member===p);h=!!(e&&e.items[0]!==p&&f(e.items[0])),m=h&&g?n.items.indexOf(e.items[0]):n.items.indexOf(p)}else m=n.items.length;let _=m>0?n.items[m-1].loc.end.line:0,v=m0&&m0){let n=e===0?_:o[e-1].endLine,a=e===0&&v!==void 0?v-_:o[e].startLine-n;a!==(t(i)||r(i)?2:1)&&(s=a)}V(n,n,i,b,void 0,void 0,s),b++}if(u!==void 0){U(n);let e=a[a.length-1],t=n.items.indexOf(e)+1;if(t0&&(o.equals+=t,W(o.value,{lines:0,columns:t},{first_line_only:!0}),Bn(n,o.value,{lines:0,columns:t}));return}throw Error(`Cannot rename "${d.raw}" to "${f.raw}": the replacement key has ${f.value.length} segment(s) where the existing key has ${d.value.length}, so one cannot be substituted for the other.`)}f.raw=Na(d.raw,f.value),Ia(f,d.raw),f.loc.end.column=f.loc.start.column+f.raw.length,Kn(n,m(l)?l:o,d,f)}}),U(n);for(let e=1;e0&&W(i,{lines:-e,columns:0})}}let de=n.items;for(let e=0;e1){let n=1-t;for(let t=e+1;t{Rn(e)&&(fe=!0)},InlineArray:e=>{Rn(e)&&(fe=!0)}}),fe&&U(n);let N=!1,pe=[];if(Dt(n,{InlineTable:e=>{Rn(e)&&(pe.push(e),Ka(e),zn(e),er(n,e,c.bracketSpacing),N=!0)},InlineArray:e=>{Rn(e)&&(Ka(e),zn(e),er(n,e,c.bracketSpacing),N=!0)}}),N){U(n);for(let e of pe)qa(n,e);Ja(n)}if(Xa(n),Za(n,x,c),oa(n,oe,g),oe.length>0&&Vn(n),se.size>0){U(n);for(let e of se)Qr(e,c.indentWidth)}if(ce.length>0){U(n);for(let{owner:e,container:t}of ce)Ba(t),Vr(t,e.loc.start.column,c.indentWidth,c.bracketSpacing)}return n}function Ba(e){if(delete e.range,p(e))for(let t of e.items)Ba(t);m(e)&&Ba(e.item),a(e)&&(Ba(e.key),Ba(e.value))}function Va(e){return e.items.findIndex(e=>t(e)||r(e))}function Ha(e){let t=Va(e);return t===-1?void 0:t}function Ua(e,t){let n=e.items.indexOf(t);if(n<0)return;let r=Va(e);r===-1||r>n||(H(e,e,t),U(e),V(e,e,t,r))}function Wa(e,n){let i=e.items.indexOf(n);if(i<0)return;let o=e.items.findIndex((e,n)=>n>i&&(t(e)||r(e)));if(!e.items.some((e,t)=>t>i&&a(e)&&(o===-1||ttypeof e==`number`);if(n>0&&i.path.slice(n+1).every(e=>typeof e==`string`)){let s=Z(e,i.path.slice(0,n+1));if(s&&r(s)){let r=s.key.item.value,c=i.path.slice(n+1),l=$(e,r.concat(c));for(let t of l)Q(e,e,t);let u=s.items.filter(e=>{let t=a(e)?e.key.value:void 0;return t&&t.length>c.length&&I(t.slice(0,c.length),c)});for(let t of u)Q(e,s,t);let d=f;for(let e=i.path.length-1;e>n;e--)d={[i.path[e]]:d};let p=ai(q(d,o).items,o),m=Array.from(Be(p)),h=t=>{let n=s.items.find(e=>a(e)&&I(e.key.value,t.key.value));n?Kn(e,s,n,t):V(e,s,t,void 0)};for(let e of m){if(a(e)){h(e);continue}if(t(e)){let t=e.key.item.value;for(let n of e.items){if(!a(n))continue;let e=n.key.raw,r=t.concat(n.key.value);n.key.value=r,n.key.raw=yr(r).raw;let i=n.key.raw.length-e.length;n.key.loc.end.column=n.key.loc.start.column+n.key.raw.length,n.equals+=i,W(n.value,{lines:0,columns:i},{first_line_only:!0}),n.loc.end.line===n.loc.start.line&&(n.loc.end.column+=i),h(n)}}}return}}}let p=f;for(let e=i.path.length-1;e>=0;e--)p={[i.path[e]]:p};let m=$(e,i.path);for(let t of m)H(e,e,t);let h=ai(q(p,o).items,o),g=Array.from(Be(h)),_=Ha(e);_!==void 0&&U(e);let v;for(let n=0;nt(e)&&I(e.key.item.value,i.key.item.value)):void 0;if(o){for(let t of i.items)V(e,o,t,void 0);v||=o;continue}if(a(i)&&d(i.value)){let o=i.key.value,s=e.items.find(e=>t(e)&&I(e.key.item.value,o));if(s){for(let t of i.value.items)V(e,s,u(t)?t.item:t,void 0);v||=s;continue}if($(e,o).filter(e=>a(e)||t(e)||r(e)).length>0){for(let t of i.value.items){let r=u(t)?t.item:t;if(a(r)){let e=r.key.raw,t=o.concat(r.key.value);r.key.value=t,r.key.raw=t.map(e=>k.test(e)?e:JSON.stringify(e).replace(/\x7f/g,`\\u007f`)).join(`.`);let n=r.key.raw.length-e.length;r.key.loc.end.column=r.key.loc.start.column+r.key.raw.length,r.equals+=n,W(r.value,{lines:0,columns:n},{first_line_only:!0}),r.loc.end.line===r.loc.start.line&&(r.loc.end.column+=n)}V(e,e,r,n===0?_:void 0),v||=r}continue}}if(t(i)){let t=i.key.item.value;if($(e,t).filter(e=>a(e)).length>0){for(let r of i.items){if(a(r)){let e=r.key.raw,n=t.concat(r.key.value);r.key.value=n,r.key.raw=n.map(e=>k.test(e)?e:JSON.stringify(e).replace(/\x7f/g,`\\u007f`)).join(`.`);let i=r.key.raw.length-e.length;r.key.loc.end.column=r.key.loc.start.column+r.key.raw.length,r.equals+=i,W(r.value,{lines:0,columns:i},{first_line_only:!0}),r.loc.end.line===r.loc.start.line&&(r.loc.end.column+=i)}V(e,e,r,n===0?_:void 0),v||=r}continue}}n>0&&v&&t(v)&&a(i)?V(e,v,i,void 0):V(e,e,i,n===0?_:void 0),v||=i}let y=v;t(y)&&l.add(y),c.add(y)}function Ka(e){if(e.items.length>0){let t=e.items[e.items.length-1];e.loc.end.column=t.loc.end.column+1}else e.loc.end.line=e.loc.start.line,e.loc.end.column=e.loc.start.column+2}function qa(e,t){let n=[],r=e=>{if(n.push(e),e===t)return!0;if(a(e)){if(r(e.value))return!0}else if(u(e)){if(r(e.item))return!0}else if(p(e)){for(let t of e.items)if(r(t))return!0}return n.pop(),!1};if(!r(e))return;let i=n.findLast(u);if(!i)return;let o=t.loc.end.line-i.loc.end.line;if(o===0)return;let s=n.findLastIndex((e,t)=>t=0?c.items[m+1]:void 0;if(m<0||!h)return;let g=h.loc.start.column-f.loc.end.column,_=Ya(f)+g-h.loc.start.column;for(let e=m+1;et.loc.end.line&&(e.loc.end.line+=o);for(let e=n.length-1;e>=0;e--){let r=n[e];a(r)?(r.loc.end={...r.value.loc.end},r.value!==t&&d(r.value)&&(r.loc.end.column+=_)):u(r)&&(r.loc.end={...r.item.loc.end})}}function Ja(e){Dt(e,{InlineTable:{exit:e=>{if(e.items.length===0||e.loc.end.line!==e.loc.start.line)return;let t=e.items[e.items.length-1],n=Ya(t),r=e.loc.end.column,i=n+(r-t.loc.end.column);i!==r&&(e.loc.end.column=i)}},InlineArray:{exit:e=>{if(e.items.length===0||e.loc.end.line!==e.loc.start.line)return;let t=e.items[e.items.length-1],n=Ya(t),r=e.loc.end.column,i=n+(r-t.loc.end.column);i!==r&&(e.loc.end.column=i)}}})}function Ya(e){if(!u(e))return e.loc.end.column;if(a(e.item)){let t=e.item.value;if(d(t)||l(t))return t.loc.end.column}return d(e.item)||l(e.item)?e.item.loc.end.column:e.loc.end.column}function Xa(e){Dt(e,{KeyValue:t=>{let n=t.value;if(!l(n)||n.items.length>0)return;let r=Ni(e,[t.key.value[0]]);if(!r||!p(r))return;let i=t.loc.start.line,a=t.loc.end.line,o=r.items;for(let e=o.length-1;e>=0;e--){let t=o[e];if(!f(t))continue;let n=t.loc.start.line;n>=i&&n<=a&&o.splice(e,1)}}})}function Za(e,n,i){if(n.size!==0){for(let a of n.values()){if(a.length>1){let n=Z(e,a.slice(0,-1).concat([0]));if(n&&r(n)){let t=a[a.length-1],r=q({[t]:[]},i),o=gr([t],r.items[0].value);U(e),V(e,n,o);continue}let o;for(let n=a.length-1;n>=1;n--){let r=Z(e,a.slice(0,n));if(r&&t(r)){o=r;break}}if(o){let t=a.slice(o.key.item.value.length),n=gr(t,q({[t[t.length-1]]:[]},i).items[0].value);U(e),V(e,o,n);continue}}let n=gr(a,q({[a[a.length-1]]:[]},i).items[0].value);U(e),V(e,e,n,Ha(e))}U(e)}}function Qa(e,t,n){let r=[],i=(e,r)=>{for(let o=e.items.length-1;o>=0;o--){let s=e.items[o];if(a(s)&&d(s.value)){let c=[...e.key.item.value,...s.key.value];if(Gr(c)<(n.inlineTableStart??1)&&n.inlineTableStart!==0){let n=G(c);for(let e of s.value.items)u(e)&&a(e.item)&&V(t,n,e.item,void 0);e.items.splice(o,1),Wr(e),r.push(n),i(n,r)}}}};return i(e,r),r}function $a(e,t,n,r){let i=G([...t.key.item.value,...e.key.value]);if(d(e.value))for(let t of e.value.items)u(t)&&a(t.item)&&V(n,i,t.item,void 0);V(n,n,i,void 0),Wr(t);let o=Qa(i,n,r);for(let e of o)V(n,n,e,void 0)}function eo(e,n){let r=L({...n,inlineTableStart:0},n),i=ai(q({__tmp__:e},r).items,r),o=Array.from(Be(i))[0];if(a(o))return o.value;if(t(o)&&o.items.length>0&&a(o.items[0]))return o.items[0].value}function to(e,t){for(let n=t.length-1;n>0;n--){let r=Z(e,t.slice(0,n));if(!r||!a(r)||!d(r.value))continue;let i=Z(e,t.slice(0,n-1));if(i&&p(i))return{container:i,row:r}}}function $(e,n){let i=[];for(let o of e.items){let e;a(o)?e=o.key.value:(t(o)||r(o))&&(e=o.key.item.value),e&&I(e.slice(0,n.length),n)&&i.push(o)}return i}function no(e,t){let n=[];for(let i of t)typeof i==`number`&&$(e,n).some(r)||n.push(i);return n}function ro(e,n,i,o){let s=[...n.items];for(let c=s.length-1;c>=0;c--){let l=s[c];if(l===o)continue;let u=a(l)?l.key.value:t(l)||r(l)?l.key.item.value:void 0;u&&u.length>i.length&&I(u.slice(0,i.length),i)&&Q(e,n,l)}}function io(e,t){return e.line===t.line?e.column-t.column:e.line-t.line}function ao(e,t,n){let r={line:t,column:n},i=[],a=null;for(let t of e){let e=io(t.loc.end,r)<0,n=io(t.loc.start,r)<0;if(e)i.push(t),a=t.loc.end;else if(n&&!e)break;else break}return{truncatedCst:i,lastEndPosition:a}}var oo=class{_cst;_currentTomlString;_format;_integersAsBigInt;_temporal;constructor(e,t){let n=typeof e==`string`?e:Ot(e),r=At(n);this._currentTomlString=r,this._cst=Array.from(Be(r)),this._integersAsBigInt=(t==null?void 0:t.integersAsBigInt)??`asNeeded`,this._temporal=(t==null?void 0:t.temporal)??!1,this._format=R.autoDetectFormatWithCst(n,this._cst)}get toTomlString(){return this._format.leadingBom?`${this._currentTomlString}`:this._currentTomlString}get toJsObject(){let e=J(this._cst,this._currentTomlString,{integersAsBigInt:this._integersAsBigInt,temporal:this._temporal});return this._temporal?e:lo(e)}get cst(){return this._cst}patch(e,t){let n=L(t,this._format),r=this._currentTomlString;if(!this._temporal&&so(e)){let t=J(this._cst,r,{integersAsBigInt:this._integersAsBigInt,temporal:!1});e=co(e,t)}let i=t instanceof R||t!=null&&Object.prototype.hasOwnProperty.call(t,`indentWidth`),a=t instanceof R||t!=null&&Object.prototype.hasOwnProperty.call(t,`trailingComma`);Qt(this._cst,r);let o=Da(this._cst,e,n,i,a);this._cst=Array.from(Be(o.tomlString)),this._format=n,this._currentTomlString=o.tomlString}update(e){let t=At(e);if(t===this._currentTomlString){this._format.leadingBom=kt(e);return}let n=this._currentTomlString.split(this._format.newLine),r=zt(t),i=t.split(r),a=0;for(;a0&&d>0&&(f[0]=f[0].substring(d));let p=f.join(this._format.newLine);this._cst=Array.from(Ve(c,p,{line:u,column:d})),this._currentTomlString=t,this._format=R.autoDetectFormatWithCst(e,this._cst)}overwrite(e){let t=At(e);if(t===this._currentTomlString){this._format.leadingBom=kt(e);return}this._cst=Array.from(Be(t)),this._currentTomlString=t,this._format=R.autoDetectFormatWithCst(e,this._cst)}};function so(e,t=new WeakSet){return e instanceof Date?e.constructor===Date:!e||typeof e!=`object`||t.has(e)?!1:(t.add(e),Array.isArray(e)?e.some(e=>so(e,t)):Object.keys(e).some(n=>so(e[n],t)))}function co(e,t,n=new WeakSet){if(e instanceof Date)return e.constructor===Date&&t instanceof Date&&t.constructor!==Date&&t.getTime()===e.getTime()?t:e;if(!e||typeof e!=`object`||n.has(e))return e;if(n.add(e),Array.isArray(e)){if(!Array.isArray(t))return e;let r=!1,i=e.map((e,i)=>{let a=co(e,t[i],n);return a!==e&&(r=!0),a});return r?i:e}if(!F(t))return e;let r=!1,i={};for(let a of Object.keys(e)){let o=e[a],s=co(o,t[a],n);i[a]=s,s!==o&&(r=!0)}return r?i:e}function lo(e){if(e instanceof Date)return new Date(e.getTime());if(Array.isArray(e))return e.map(lo);if(e&&typeof e==`object`){let t={};for(let[n,r]of Object.entries(e))t[n]=lo(r);return t}return e}const uo=Object.freeze(R.default());function fo(e){let t=uo;return e.newLine===t.newLine&&e.trailingNewline===t.trailingNewline&&e.trailingComma===t.trailingComma&&e.bracketSpacing===t.bracketSpacing&&e.inlineTableStart===t.inlineTableStart&&e.truncateZeroTimeInDates===t.truncateZeroTimeInDates&&e.useTabsForIndentation===t.useTabsForIndentation&&e.indentWidth===t.indentWidth&&e.minimumDecimals===t.minimumDecimals&&e.leadingBom===t.leadingBom&&e.updateOrder===t.updateOrder&&e.multilineTable===t.multilineTable&&e.multilineArray===t.multilineArray}function po(e,t){let n=At(typeof e==`string`?e:Ot(e));return J(Be(n),n,{integersAsBigInt:(t==null?void 0:t.integersAsBigInt)??`asNeeded`,temporal:(t==null?void 0:t.temporal)??!1})}function mo(e,t){let n=t==null?uo:L(t,R.default()),r=q(e,n),i=fo(n)?si(r.items,n):ai(r.items,n);return n.leadingBom?`${i}`:i}function ho(e,t){return new oo(e,t)}export{ge as LocalDate,ve as LocalDateTime,_e as LocalTime,ye as OffsetDateTime,oo as TomlDocument,R as TomlFormat,po as parse,ho as parseDocument,xa as patch,mo as stringify}; \ No newline at end of file diff --git a/docker/t3-client/setup-bridge.js b/docker/t3-client/setup-bridge.js index c1b4b69..cbe7566 100644 --- a/docker/t3-client/setup-bridge.js +++ b/docker/t3-client/setup-bridge.js @@ -69,7 +69,8 @@ const PALETTE_ITEMS = [ { id: 'setup', route: null, title: 'Open setup', meta: 'Agents, skills, toolchains, source control, ports, devices and updates on this server', terms: ['setup console'] }, { id: 'agents', route: 'agents', title: 'Agents', meta: 'Setup · sign in, install and update', terms: [] }, - { id: 'context', route: 'instructions', title: 'Instructions & skills', meta: 'Setup · global AGENTS.md and skills', terms: ['agents.md global instructions', 'context'] }, + { id: 'context', route: 'instructions', title: 'Instructions & skills', meta: 'Setup · global AGENTS.md and skills', terms: ['agents.md global instructions', 'context', 'skills'] }, + { id: 'mcp', route: 'mcp', title: 'MCP servers', meta: 'Setup · shared definitions and provider configuration', terms: ['mcp tools servers'] }, { id: 'pair', route: 'access', title: 'Pair a device', meta: 'Setup · Access', terms: ['devices', 'pairing'] }, { id: 'toolchains', route: 'toolchains', title: 'Toolchains', meta: 'Setup · Go, Rust, Bun, Deno, uv and any mise tool', terms: [] }, { id: 'sourcecontrol', route: 'sourcecontrol', title: 'Source control', meta: 'Setup · sign in to GitHub, GitLab, Forgejo, Gitea and Azure DevOps', terms: ['codeberg', 'gh glab fj tea az'] }, diff --git a/docs/agent-browser.md b/docs/agent-browser.md index 3282302..5a94fe0 100644 --- a/docs/agent-browser.md +++ b/docs/agent-browser.md @@ -31,7 +31,10 @@ docker compose exec t3code t3-browser-mcp --remove This registers headless Chromium as an MCP server with the installed Claude Code, Codex and OpenCode, so agents can navigate, take screenshots, click and -read the browser console, whichever client you use. Run it after the first +read the browser console, whichever client you use. The console's +**Instructions & skills → MCP servers** tab can define these commands and +apply them per provider, with the same restart rule. `t3-browser-mcp` writes +provider entries directly without creating shared definitions. Run either after the first start has installed those agents (the setup page shows when), then start a new thread, because agents read their MCP configuration when a session starts. diff --git a/docs/context-management.md b/docs/context-management.md index f4b6734..6a370a0 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -2,7 +2,8 @@ **Setup → Instructions & skills** edits the shared AGENTS.md and each agent's own instruction file, on its **Instructions** tab; skills are on its **Skills** -tab (`#instructions/skills`). A two-column overview shows a safe text preview of +tab (`#instructions/skills`). Shared servers live on the separate +[MCP servers](mcp-servers.md) page (`#mcp`). A two-column overview shows a safe text preview of AGENTS.md beside the per-agent source choices. The instruction editor is centered on desktop and opens as a bottom sheet on phones. Uploading a Markdown file replaces the draft; only **Save** applies it. Downloads include unsaved edits. @@ -429,6 +430,10 @@ settings. A failed verification reports that the policy may already be saved; it does not roll back over a possible external change. External applications can still race the final check. Start a new conversation to load policy changes. +### MCP servers + +See [MCP servers](mcp-servers.md) for the separate server library, native agent copies and sign-in. + ## Validation `node --test tests/*.test.mjs` checks file writes, existing-file discovery and @@ -472,6 +477,15 @@ confirmation, native state failures, busy controls and late provider responses. also cover catalog selection, raw Markdown previews, provider selection, replacement consent/conflicts, failed backup moves/restoration, supporting-file edits with restored mtimes, shared-root retargeting and partial results. +Unit/HTTP checks for shared MCP servers (`tests/mcp.test.mjs`, +`tests/mcp-api.test.mjs`, `tests/mcp-regressions.test.mjs`) cover store validation and revisions, TOML section +preservation, provider translation round-trips, link/diverged detection, +comment-preserving JSONC writes, unsupported providers and the authenticated +boundary including secret redaction in polling responses. `scripts/mcp-ui-audit.js` +checks agent selection, immediate refresh, editor revisions, lossless JSON +values, separate transport drafts, save/discard guards, apply/unlink, failed/late +reads and mobile/desktop/embedded layouts in both themes. Its API +responses are fixtures; no real provider configuration is modified. The adapters were also checked with Codex 0.163.0 in a disposable home. Native enable/disable, stale-request rejection, settings persistence across fresh diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md new file mode 100644 index 0000000..57c2070 --- /dev/null +++ b/docs/mcp-servers.md @@ -0,0 +1,185 @@ +# MCP servers + +Open **Setup → MCP servers** (or press **G**, then **M**). Define a local +command or Streamable HTTP server once, then choose which installed agents +receive it. Setup never starts a command, connects to a server or signs in for +you. Each agent loads its own copy when a new session starts. + +## The library + +The page compares shared definitions with the agents’ global settings. Search +names, commands or URL origins; filter by agent or **Needs attention**. **Rescan** +reads the current files. Status polling leaves open menus, focused controls and +form drafts alone. On phones the matrix becomes a grouped list, with agent +filters and an Add server action above the safe area. + +**Shared servers** are defined in `$T3CODE_HOME/setup/mcp/servers.json`. **Only in +an agent’s settings** lists entries already configured outside Setup. Share one +to manage it here. Unsupported entries remain visible and stay unchanged. + +| Cell | Meaning and actions | +| --- | --- | +| On | Matches the shared definition and is configured to start in a new session. | +| Off | Codex or OpenCode keeps the entry but does not start it. Its content still matches. | +| Differs | The native copy has different fields or agent-specific options. Review before replacing it. | +| — | Not added. Use Add to copy the definition into this agent’s settings. | +| Own entry | Native settings contain this server; it has no shared definition yet. | +| Via Claude Code / Via Cursor | Grok may import that copy. It does not report what it actually loads. | + +These states describe configuration, not connectivity. Unreadable settings get +an attention notice and disable that agent’s controls. While a change saves, its +cell shows progress and that agent’s column is disabled; other agents remain usable. + +The **Installed agents / All agents** menu beside **Server** controls the matrix +columns, as it does in Skills. It stays visible when the table scrolls sideways. +All agents also shows uninstalled agents; their saved entries are read-only. +Muse Code and Antigravity show **Not managed here**, and Grok remains imports only. +Changing the visible columns never adds a server or changes native settings. + +Claude Code and Cursor have Add/Remove here. Codex and OpenCode also support +On/Off. Claude’s per-project toggles are outside Setup’s inventory. A legacy +shared `enabled: false` value is still readable and makes new Codex/OpenCode +copies start off; it no longer blocks adding a copy to Claude or Cursor. + +## Add, edit and share + +**Add server…** accepts Claude/Cursor JSON or JSONC, Codex TOML, OpenCode V1/V2 +JSON, a `claude mcp add` command, a plain command line or an HTTP URL. Nothing +pasted is run. When several servers are found, choose one and add the others +later. Placeholder values get a warning; replace them with your own credentials. +Variable references remain literal and follow each agent’s own rules. + +Command lines use quoting without shell expansion. **Edit one per line** provides +one field per argument and preserves empty arguments, surrounding whitespace and +line breaks. It is selected automatically for values that need it. Local and +remote drafts stay separate when you switch transport. Environment and header +values are masked by default; multiline values remain intact. Pasting `KEY=value` +lines into a variable name adds rows. + +The **Use in** rail reviews which settings files will change. A same-name native +entry is unticked by default when adding; replacing it requires consent. A failed +snippet leaves the current form intact. Late parsing results do not overwrite +manual edits made while parsing was pending. + +**Edit server…** saves the shared definition, then updates checked agents in +sequence. Agents that matched are selected by default. Differing copies require +an explicit selection; unchecked agents keep their copy and show Differs after +the shared definition changes. Agents already Off stay Off. Names cannot change: +add a server under the new name, then remove the old one. + +**Share…** reads the native versions for a server. Choose a source when they +differ, then consent before replacing another agent’s entry. Secret values are +copied as they are. Options the shared format cannot hold are listed as dropped; +the source file stays exactly as it is. Creating or replacing the shared +definition never writes an agent’s file by itself. + +Saves block dismissal and navigation until their results arrive. Closing an +unsaved form asks whether to discard it, and reload warns while a draft or save +is pending. A partial save reports exactly which agents succeeded and keeps a +result view for failures. Review current settings before trying again; successful +writes are already saved. A revision conflict requires a fresh review. + +## Inspect, update and remove + +Open a shared server’s name or **Review…** to see each agent’s copy, its saved +location and a field-by-field difference. Environment/header differences show key +names and whether values differ, never secret values side by side. Connection +values are revealed only on request and are not announced in a live region. + +**Update to shared** replaces the complete native entry, including agent-specific +options. A menu asks for confirmation; the inspector already shows the reviewed +differences. Removing a matching copy offers revision-checked Undo, preserving its +Off state. Removing a differing copy asks for confirmation because its own edits +would be lost. Each changed settings file gets a private backup beside its +resolved target (`.t3-mcp-backup.`). + +**Remove…** lists every supported agent that still has the server, including +differing copies. It unlinks them in sequence and deletes the shared definition +last. If one write fails, removal stops, the definition remains and the dialog +shows the failure and remaining copies. An unreadable settings file blocks +removal, so an unknown copy is never silently left behind. Existing sessions may +keep using a removed server until they end. + +## Sign-in and scope + +OAuth is separate for each agent. If a remote server asks to sign in, use the +command shown in its inspector from a T3 Code terminal: + +```sh +claude mcp login SERVER_NAME +codex mcp login SERVER_NAME +opencode mcp auth SERVER_NAME +cursor-agent mcp login SERVER_NAME +``` + +Native approval, project configuration, custom T3 provider profiles, enterprise +policy and effective runtime discovery remain with the agents. Grok is imports +only; Muse Code and Antigravity are not managed here. SSE-only addresses cannot +be imported as portable HTTP servers; use the server’s Streamable HTTP address. + +## Files and safety + +The store is owner-only (0600), limited to 128 servers and 256 KiB. Native files +have a 1 MiB read/write limit. Every native write checks the shared and provider +revisions, runs through the same native-settings queue as Skills, preserves +symlinks and unrelated settings/comments, and publishes atomically. Backups hold +secret values; remove them manually when no longer needed. This is optimistic +concurrency, not a cross-process transaction against other applications. + +Polling and list responses expose environment/header key names and counts, and +remote URL origins only. Full URLs and credential values load through +authenticated detail reads. Commands are visible in lists, so put credentials in +Environment or Headers. URL userinfo is rejected. The snippet parser is pure, +limited to 64 KiB, and performs no execution, expansion, network calls or writes. + +| Provider | Native global config | Support in this release | +| --- | --- | --- | +| Claude Code | `~/.claude.json`, or `$CLAUDE_CONFIG_DIR/.claude.json` | Local command/args/env and HTTP URL/headers | +| Codex | `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`) | Local and HTTP; native `enabled`; full TOML parsing with edits limited to the selected server | +| OpenCode 1 | `opencode.json[c]`, `.mcp[name]` | Local and HTTP; native `enabled` | +| OpenCode 2 | `opencode.json[c]`, `.mcp.servers[name]` | Local and HTTP; native `disabled`; existing native V2 files stay V2; new files use the V1-compatible format also accepted by V2 | +| Cursor | `~/.cursor/mcp.json` | Local and HTTP; native approval/login still applies | +| Antigravity | T3's isolated provider profile | Not integrated; Gemini CLI settings are not an Antigravity integration | +| Grok Build | `~/.grok/config.toml` | Not managed; Grok may also load Claude/Cursor MCP entries through native compatibility imports | +| Muse Code | `$XDG_CONFIG_HOME/muse/settings.json` | Not managed in this release | + +Claude HTTP entries require an explicit transport type; a URL alone is +reported as differing. The documented `streamable-http` alias is recognized. +OpenCode JSONC wins if both files exist. V2 global options are preserved even +when the config has no server dictionary yet. Entries using the other layout's +state flag (`enabled` in V2 or `disabled` in V1) show **Differs** until applied +with the correct flag. Codex uses a pinned vendored TOML +syntax tree; nested tables, arrays of tables and unrelated comments are preserved. +All provider writes share the native-settings serialization queue with skills, +follow existing file/directory symlinks, reject stale revisions/retargeting and +publish atomically with owner-only permissions. Other applications can still +race between the final check and rename; this is not a cross-process transaction. +The page manages default global files, honoring the server environment’s +Claude/Codex/XDG/OpenCode directory overrides. With credential persistence enabled, +Claude’s default `.claude.json` also lives on the state volume. Custom roots need +their own persistent mounts. + +`t3-browser-mcp` remains a separate quick CLI path for the bundled browser +servers. It writes native entries directly and does not create shared definitions. + +## Verification + +Run `node --test tests/mcp*.test.mjs` for parsing, per-agent state, adoption, +revisions, redaction, format preservation and the authenticated API boundary. +`node scripts/mcp-ui-audit.js URL KEY` exercises desktop, phone and embedded flows +in both themes with synthetic data. All API mutations are intercepted. + +`node scripts/mcp-native-smoke.mjs PROVIDER /absolute/path/to/executable` verifies +local/HTTP handshakes, credential delivery, unlink and Codex/OpenCode Off/On using +fresh native reads, disposable homes and fixture servers. It never sends model +prompts or real credentials. This checks native configuration and MCP negotiation, +not model-selected tool calls or OAuth. + +Native contracts: [Claude](https://code.claude.com/docs/en/mcp), +[Codex](https://learn.chatgpt.com/docs/extend/mcp?surface=cli), +[OpenCode V1](https://opencode.ai/docs/mcp-servers/), +[OpenCode V2](https://dev.opencode.ai/v2/docs/mcp-servers/), +[Cursor](https://cursor.com/docs/cli/mcp), +[Antigravity](https://antigravity.google/docs/cli/gcli-migration/), +[Grok](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/07-mcp-servers.md), +and [Muse](https://meta-models.github.io/muse-code-sdk/next/guides/extend/mcp-servers/). diff --git a/docs/media/mcp/all-agents-light.png b/docs/media/mcp/all-agents-light.png new file mode 100644 index 0000000..93502a5 Binary files /dev/null and b/docs/media/mcp/all-agents-light.png differ diff --git a/docs/media/mcp/columns-menu-dark.png b/docs/media/mcp/columns-menu-dark.png new file mode 100644 index 0000000..dac403a Binary files /dev/null and b/docs/media/mcp/columns-menu-dark.png differ diff --git a/docs/media/mcp/desktop-dark.png b/docs/media/mcp/desktop-dark.png new file mode 100644 index 0000000..2eb8db2 Binary files /dev/null and b/docs/media/mcp/desktop-dark.png differ diff --git a/docs/media/mcp/desktop-light.png b/docs/media/mcp/desktop-light.png new file mode 100644 index 0000000..aa1300a Binary files /dev/null and b/docs/media/mcp/desktop-light.png differ diff --git a/docs/media/mcp/phone-dark.png b/docs/media/mcp/phone-dark.png new file mode 100644 index 0000000..8fc1ff3 Binary files /dev/null and b/docs/media/mcp/phone-dark.png differ diff --git a/docs/media/mcp/phone-focus-dark.png b/docs/media/mcp/phone-focus-dark.png new file mode 100644 index 0000000..e901cab Binary files /dev/null and b/docs/media/mcp/phone-focus-dark.png differ diff --git a/docs/media/mcp/phone-focus-light.png b/docs/media/mcp/phone-focus-light.png new file mode 100644 index 0000000..df1a5f9 Binary files /dev/null and b/docs/media/mcp/phone-focus-light.png differ diff --git a/docs/media/mcp/phone-light.png b/docs/media/mcp/phone-light.png new file mode 100644 index 0000000..39216a2 Binary files /dev/null and b/docs/media/mcp/phone-light.png differ diff --git a/docs/setup-page.md b/docs/setup-page.md index 14f2328..a03d255 100644 --- a/docs/setup-page.md +++ b/docs/setup-page.md @@ -132,4 +132,9 @@ allow/ask/deny user policies and reports the Build agent's effective rule. Unmanaged shared skills can be added, edited and removed from Codex/OpenCode. Other providers show **Found** with a discovery-only explanation. Phone layouts use grouped lists and bottom sheets. Status polling does not rescan skills or reset drafts. +The separate **[MCP servers](mcp-servers.md)** page defines servers once and +compares every installed agent’s native copy. Add/Remove, per-agent On/Off, +difference review, snippet import, editing, sharing and removal all check native +revisions and preserve backups. Its inspector masks credentials until revealed. +Grok Build is imports only; Muse Code and Antigravity are not managed here. See [Context management](context-management.md) for paths and verification. diff --git a/scripts/console-preview.mjs b/scripts/console-preview.mjs index 9ecee66..b25a03d 100644 --- a/scripts/console-preview.mjs +++ b/scripts/console-preview.mjs @@ -9,14 +9,17 @@ const require = createRequire(import.meta.url); const { statusFor, portsFor, fixture } = require('./lib/console-fixtures.cjs'); const { createSkillsFixtures } = require('./lib/skills-fixtures.cjs'); const { createStorageFixtures } = require('./lib/storage-fixtures.cjs'); +const { createMcpFixtures } = require('./lib/mcp-fixtures.cjs'); +const mcp = createMcpFixtures(); const api = createSkillsFixtures(); const storage = createStorageFixtures(); const port = Number(process.argv[process.argv.indexOf('--port') + 1]) || 23785; http.createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/.well-known/t3/environment') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ environmentId: 'fixture-preview', serverVersion: 'fixture', label: 'Fixture preview' })); return; } if (url.pathname === '/__setup/' || url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store', 'content-security-policy': contentSecurityPolicy('fixture') }); - res.end(renderConsole({ assets: loadAssets(), nonce: 'fixture', mount: '/__setup', embed: url.searchParams.has('embed') })); + res.end(renderConsole({ assets: loadAssets(), nonce: 'fixture', mount: '/__setup', embed: url.searchParams.get('embed') === 't3' })); return; } const chunks = []; let bytes = 0; @@ -24,7 +27,7 @@ http.createServer(async (req, res) => { let input; try { input = chunks.length ? JSON.parse(Buffer.concat(chunks)) : {}; } catch { res.writeHead(400); res.end(); return; } - const result = storage.respond(req.method, url, input) || api.respond(req.method, url, input) || (url.pathname.endsWith('/status') ? { body: { ...statusFor('skills'), storage: storage.status() } } + const result = await mcp.respond(req.method, url, input) || storage.respond(req.method, url, input) || api.respond(req.method, url, input) || (url.pathname.endsWith('/status') ? { body: { ...statusFor('skills'), storage: storage.status(), context: { ...statusFor('skills').context, mcp: mcp.status() } } } : url.pathname.endsWith('/ports') ? { body: portsFor('ready') } : url.pathname.endsWith('/providers') ? { body: fixture('providers.json') } : { status: 404, body: { ok: false, error: 'This is a read-only fixture preview. No live API is connected.' } }); diff --git a/scripts/console-screens.js b/scripts/console-screens.js index 560cc8f..760ed8d 100644 --- a/scripts/console-screens.js +++ b/scripts/console-screens.js @@ -62,6 +62,12 @@ const withSkillOverlays = args.includes("--skill-overlays"); // The overlays worth a picture: how to reach each from a loaded console. Each // returns false when the scenario has nothing to open. const OVERLAYS = [ + ['mcp-inspector', async (t) => { + await t.evaluate(() => { location.hash = 'mcp'; }); + const edit = await t.waitForSelector('[data-cmd="mcp.view"]', { timeout: 5000 }).catch(() => null); + if (!edit) return false; + await edit.click(); await t.waitForSelector('.tc-mcp-inspector'); return true; + }], ["palette", async (t, page) => { await t.evaluate(() => { location.hash = "overview"; }); await page.keyboard.press("Control+k"); await t.waitForSelector(".tc-layer .tc-palette"); return true; }], ["device-menu", async (t) => { await t.evaluate(() => { location.hash = "access"; }); diff --git a/scripts/fixtures/console/mcp.json b/scripts/fixtures/console/mcp.json new file mode 100644 index 0000000..bb5bc75 --- /dev/null +++ b/scripts/fixtures/console/mcp.json @@ -0,0 +1,46 @@ +{ + "ok": true, + "shared": { + "present": true, + "count": 3, + "revision": "mcp-fixture", + "bytes": 812, + "error": null, + "servers": [ + { "name": "playwright", "type": "local", "command": ["playwright-mcp", "--headless", "--isolated"], "envKeys": [], "envCount": 0, "headerKeys": [], "headerCount": 0, "secretCount": 0 }, + { "name": "docs", "type": "remote", "url": "https://docs.example.test", "envKeys": [], "envCount": 0, "headerKeys": ["Authorization"], "headerCount": 1, "secretCount": 1 }, + { "name": "database", "type": "local", "command": ["database-mcp"], "envKeys": ["DATABASE_URL"], "envCount": 1, "headerKeys": [], "headerCount": 0, "secretCount": 1 } + ] + }, + "providers": [ + { "provider": "claude", "supported": true, "path": "/home/t3/.claude.json", "revision": "claude-fixture", + "linked": ["playwright"], "diverged": ["docs"], "unlinked": ["database"], "disabled": [], + "own": [ { "name": "github", "type": "local", "command": ["github-mcp-server", "stdio"], "envKeys": ["GITHUB_PERSONAL_ACCESS_TOKEN"], "headerKeys": [], "recognized": true, "extra": [] } ], + "ownCount": 1, "error": null, "warning": null }, + { "provider": "codex", "supported": true, "path": "/home/t3/.codex/config.toml", "revision": "codex-fixture", + "linked": ["playwright", "docs", "database"], "diverged": [], "unlinked": [], "disabled": ["database"], + "own": [], "ownCount": 0, "error": null, "warning": null }, + { "provider": "opencode", "supported": true, "path": "/home/t3/.config/opencode/opencode.jsonc", "revision": "opencode-fixture", + "linked": ["docs"], "diverged": [], "unlinked": ["playwright", "database"], "disabled": [], + "own": [ + { "name": "github", "type": "local", "command": ["docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], "envKeys": ["GITHUB_PERSONAL_ACCESS_TOKEN"], "headerKeys": [], "recognized": true, "extra": [] }, + { "name": "filesystem", "type": "local", "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"], "envKeys": [], "headerKeys": [], "recognized": true, "extra": [] } + ], + "ownCount": 2, "error": null, "warning": null }, + { "provider": "cursor", "supported": true, "path": "/home/t3/.cursor/mcp.json", "revision": "cursor-fixture", + "linked": [], "diverged": [], "unlinked": ["playwright", "docs", "database"], "disabled": [], + "own": [], "ownCount": 0, "error": null, "warning": null }, + { "provider": "grok", "supported": false, "imports": ["claude", "cursor"], + "error": "Grok MCP management is not implemented. Grok may inherit Claude and Cursor servers through compatibility imports.", + "linked": [], "diverged": [], "unlinked": [], "disabled": [], "own": [], "ownCount": 0 }, + { "provider": "muse", "supported": false, "error": "Muse MCP management is not implemented yet.", + "linked": [], "diverged": [], "unlinked": [], "disabled": [], "own": [], "ownCount": 0 }, + { "provider": "antigravity", "supported": false, "error": "Antigravity uses isolated T3 profiles; shared MCP settings are not integrated yet.", + "linked": [], "diverged": [], "unlinked": [], "disabled": [], "own": [], "ownCount": 0 } + ], + "ownGroups": [ + { "name": "github", "providers": ["claude", "opencode"], "identical": false }, + { "name": "filesystem", "providers": ["opencode"], "identical": true } + ], + "scannedAt": 1792729440000 +} diff --git a/scripts/lib/chip-focus.cjs b/scripts/lib/chip-focus.cjs new file mode 100644 index 0000000..7c1d8e3 --- /dev/null +++ b/scripts/lib/chip-focus.cjs @@ -0,0 +1,34 @@ +// Shared phone filters must keep their external focus ring inside the scrollport. +// Exercise actual Tab scrolling in both directions, including forced colors. +const assert = require('node:assert/strict'); +const checkChipFocus = async (page, chips, screenshot) => { + const buttons = chips.locator('button'), count = await buttons.count(); + assert.ok(count > 1, 'Agent-filter fixture needs multiple chips'); + try { + for (const forcedColors of ['none', 'active']) { + await page.emulateMedia({ forcedColors }); + await buttons.first().focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + const check = async index => { + const state = await buttons.nth(index).evaluate(button => { + const row = button.closest('.tc-sk-agent-chips'), port = row.getBoundingClientRect(), box = button.getBoundingClientRect(), style = getComputedStyle(button); + const ring = Math.max(0, parseFloat(style.outlineWidth) + parseFloat(style.outlineOffset)); + return { label: button.textContent, focused: button === document.activeElement && button.matches(':focus-visible'), outline: style.outlineStyle, width: parseFloat(style.outlineWidth), height: box.height, + inPort: box.left - ring >= port.left + row.clientLeft - 0.5 && box.right + ring <= port.left + row.clientLeft + row.clientWidth + 0.5 && box.top - ring >= port.top + row.clientTop - 0.5 && box.bottom + ring <= port.top + row.clientTop + row.clientHeight + 0.5, + inPage: box.left - ring >= 0 && box.right + ring <= innerWidth && document.documentElement.scrollWidth <= innerWidth + 1 }; + }); + assert.ok(state.focused && state.outline !== 'none' && state.width >= 2, 'Visible keyboard focus: ' + JSON.stringify(state)); + assert.ok(state.inPort && state.inPage, 'Complete chip focus ring stays visible: ' + JSON.stringify(state)); + assert.ok(state.height >= 44, 'Phone filter keeps a 44px touch target'); + }; + for (let index = 0; index < count; index++) { + if (index) await page.keyboard.press('Tab'); + await check(index); + if (forcedColors === 'none' && (index === 0 || index === count - 1)) await screenshot('agent-filter-' + (index ? 'last' : 'first')); + } + for (let index = count - 2; index >= 0; index--) { await page.keyboard.press('Shift+Tab'); await check(index); } + } + } finally { await page.emulateMedia({ forcedColors: 'none' }); } +}; +module.exports = { checkChipFocus }; diff --git a/scripts/lib/console-fixtures.cjs b/scripts/lib/console-fixtures.cjs index 672b338..ce9654a 100644 --- a/scripts/lib/console-fixtures.cjs +++ b/scripts/lib/console-fixtures.cjs @@ -90,6 +90,14 @@ const statusFor = (scenario, change) => { const base = fixture("status.json"); const now = Date.now(); const status = SCENARIOS[scenario](shiftTimes(base, now - base.storage.at), now); + const mcp = fixture('mcp.json'); + if (scenario === 'setup') { + Object.assign(mcp.shared, { present: false, count: 0, servers: [] }); + mcp.ownGroups = []; + for (const row of mcp.providers) Object.assign(row, { linked: [], unlinked: [], diverged: [], disabled: [], own: [], ownCount: 0 }); + } + if (scenario === 'attention') Object.assign(mcp.providers.find(p => p.provider === 'codex'), { error: 'Codex config.toml cannot be safely edited. Check its TOML syntax before changing MCP servers.', linked: [], unlinked: ['playwright', 'docs', 'database'] }); + status.context = { ...status.context, mcp }; return change ? change(status, now) || status : status; }; const portsFor = (scenario, change) => { @@ -103,10 +111,13 @@ const portsFor = (scenario, change) => { */ const routeConsole = async (page, { status, ports, skillOptions }) => { const skills = require('./skills-fixtures.cjs').createSkillsFixtures(skillOptions); - await page.route("**/*", (route) => { + const mcp = require('./mcp-fixtures.cjs').createMcpFixtures(status().context?.mcp); + await page.route("**/*", async (route) => { const request = route.request(); const url = new URL(request.url()); const at = (suffix) => url.pathname.endsWith(suffix); + const mcpResponse = await mcp.respond(request.method(), url, request.method() === 'POST' ? request.postDataJSON() : undefined); + if (mcpResponse) return route.fulfill({ status: mcpResponse.status, json: mcpResponse.body }); const skillResponse = skills.respond(request.method(), url, request.method() === 'POST' ? request.postDataJSON() : undefined); if (skillResponse) return route.fulfill({ status: skillResponse.status, json: skillResponse.body }); if (request.method() === "POST" && !at("/login")) return route.fulfill({ json: { ok: true } }); diff --git a/scripts/lib/mcp-fixtures.cjs b/scripts/lib/mcp-fixtures.cjs new file mode 100644 index 0000000..466b54e --- /dev/null +++ b/scripts/lib/mcp-fixtures.cjs @@ -0,0 +1,93 @@ +// Synthetic MCP files in memory. The real parser/difference implementation is +// reused; this adapter cannot access an agent home, execute or connect servers. +const path = require('node:path'); +const fs = require('node:fs'); +const fixture = () => JSON.parse(fs.readFileSync(path.join(__dirname, '../fixtures/console/mcp.json'), 'utf8')); +const { pathToFileURL } = require('node:url'); +const setup = pathToFileURL(process.env.T3_SETUP_MODULE || (fs.existsSync(path.join(__dirname, '../../docker/setup/mcp.mjs')) ? path.join(__dirname, '../../docker/setup/mcp.mjs') : '/opt/t3-setup/mcp.mjs')).href; +const clone = value => JSON.parse(JSON.stringify(value)); +const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); +function createMcpFixtures(initial) { + let data = clone(initial || fixture()), revision = 1; + data.scannedAt = Date.now() - 120000; + const definitions = Object.fromEntries(data.shared.servers.map(s => [s.name, s.type === 'remote' ? { name: s.name, type: 'remote', url: 'https://docs.example.test/mcp', headers: { Authorization: 'Bearer synthetic-shared-token' } } + : { name: s.name, type: 'local', command: s.command, env: Object.fromEntries((s.envKeys || []).map(k => [k, 'synthetic-' + k])) }])); + const entries = {}; + for (const p of data.providers.filter(p => p.supported)) { + entries[p.provider] = {}; + for (const name of [...p.linked, ...p.diverged]) entries[p.provider][name] = { server: clone(definitions[name]), extra: [], enabled: !p.disabled.includes(name) }; + for (const own of p.own) entries[p.provider][own.name] = { server: { name: own.name, type: own.type, command: own.command, env: Object.fromEntries((own.envKeys || []).map(k => [k, 'synthetic-' + k])) }, enabled: true, extra: own.extra || [] }; + } + if (entries.claude?.docs) { entries.claude.docs.server.url = 'https://docs.example.test/v1/mcp'; entries.claude.docs.server.headers.Authorization = 'Bearer synthetic-agent-token'; } + const summarize = s => ({ name: s.name, type: s.type, ...(s.type === 'local' ? { command: s.command } : { url: new URL(s.url).origin }), envKeys: Object.keys(s.env || {}), headerKeys: Object.keys(s.headers || {}) }); + const rebuild = () => { + data.shared.servers = Object.values(definitions).map(summarize); data.shared.count = data.shared.servers.length; data.shared.present = data.shared.count > 0; + for (const p of data.providers.filter(p => p.supported)) { + p.linked = []; p.diverged = []; p.disabled = []; p.unlinked = []; p.own = []; + for (const s of Object.values(definitions)) { + const e = entries[p.provider][s.name]; + (e ? equal(e.server, s) && !e.extra.length ? p.linked : p.diverged : p.unlinked).push(s.name); + if (e && !e.enabled) p.disabled.push(s.name); + } + for (const [name, entry] of Object.entries(entries[p.provider])) if (!definitions[name]) p.own.push({ ...summarize(entry.server), recognized: true, extra: entry.extra }); + p.ownCount = p.own.length; + } + const groups = {}; + for (const p of data.providers) for (const own of p.own) (groups[own.name] ||= []).push(p.provider); + data.ownGroups = Object.entries(groups).map(([name, providers]) => ({ name, providers, identical: providers.every(id => equal(entries[id][name].server, entries[providers[0]][name].server)) })); + }; + const status = () => { rebuild(); return clone(data); }; + const failure = (code, error) => ({ status: code, body: { ok: false, code: code === 409 ? 'conflict' : 'invalid', error } }); + const respond = async (method, url, input = {}) => { + const route = url.pathname.replace(/^\/__setup/, ''); + if (!route.startsWith('/context/mcp')) return null; + const p = data.providers.find(p => p.provider === (input.provider || url.searchParams.get('provider'))), name = input.name || url.searchParams.get('name'); + const body = value => ({ status: 200, body: { ok: true, ...value } }); + if (method === 'GET') { + if (route === '/context/mcp') { if (url.searchParams.has('refresh')) data.scannedAt = Date.now(); return body(status()); } + if (route === '/context/mcp/read') { + const entry = p ? entries[p.provider]?.[name] : null, server = p ? entry?.server : definitions[name]; + if (!server) return failure(404, 'This server no longer exists.'); + return body({ server: clone(server), revision: data.shared.revision, providerRevision: p?.revision, path: p?.path, extra: entry?.extra || [] }); + } + if (route === '/context/mcp/diff') { + const entry = entries[p?.provider]?.[name]; if (!entry || !definitions[name]) return failure(404, 'Server unavailable.'); + const { mcpDifferences } = await import(setup); + return body({ provider: p.provider, name, sharedRevision: data.shared.revision, providerRevision: p.revision, ...mcpDifferences(definitions[name], { canonical: entry.server, extraOptions: entry.extra }) }); + } + } + if (method === 'POST') { + if (route === '/context/mcp/parse') return body((await import(new URL('./mcp/parse.mjs', setup))).parseMcpSnippet(input.text)); + if (route === '/context/mcp/create' || route === '/context/mcp/save') { + const { validateMcpServer } = await import(new URL('./mcp/store.mjs', setup)); const result = validateMcpServer(input.server); + if (!result.ok) return failure(400, result.error); + if (route.endsWith('/create') && definitions[result.server.name]) return failure(409, 'A shared server with that name exists.'); + if (route.endsWith('/save') && input.revision !== data.shared.revision) return failure(409, 'Shared servers changed elsewhere.'); + const { enabled, ...server } = result.server; definitions[server.name] = server; data.shared.revision = 'mcp-fixture-' + ++revision; + return body({ server, revision: data.shared.revision }); + } + if (route === '/context/mcp/adopt') { + if (!p || p.revision !== input.providerRevision || data.shared.revision !== input.sharedRevision) return failure(409, 'Settings changed elsewhere.'); + if (Boolean(definitions[name]) !== Boolean(input.replace)) return failure(409, 'Shared server changed elsewhere.'); + const entry = entries[p.provider][name]; if (!entry) return failure(404, 'Entry no longer exists.'); + definitions[name] = clone(entry.server); data.shared.revision = 'mcp-fixture-' + ++revision; rebuild(); + return body({ server: summarize(definitions[name]), revision: data.shared.revision, dropped: entry.extra }); + } + if (route === '/context/mcp/link') { + if (!p || input.sharedRevision !== data.shared.revision || input.providerRevision !== p.revision) return failure(409, 'Settings changed elsewhere.'); + if (input.enabled === false && !['codex', 'opencode'].includes(p.provider)) return failure(403, 'This agent has no global off switch.'); + if (input.linked) entries[p.provider][name] = { server: clone(definitions[name]), enabled: input.enabled ?? entries[p.provider][name]?.enabled ?? true, extra: [] }; + else delete entries[p.provider][name]; + p.revision = p.provider + '-fixture-' + ++revision; rebuild(); return body({ provider: p.provider, name, linked: input.linked, revision: p.revision }); + } + if (route === '/context/mcp/remove') { + if (input.revision !== data.shared.revision) return failure(409, 'Shared servers changed elsewhere.'); + if (Object.values(entries).some(e => e[name])) return failure(409, 'Unlink this server from every agent first.'); + delete definitions[name]; data.shared.revision = 'mcp-fixture-' + ++revision; rebuild(); return body({ revision: data.shared.revision }); + } + } + return failure(405, 'Unsupported fixture action.'); + }; + rebuild(); return { status, respond }; +} +module.exports = { createMcpFixtures }; diff --git a/scripts/mcp-native-smoke.mjs b/scripts/mcp-native-smoke.mjs new file mode 100644 index 0000000..754d64f --- /dev/null +++ b/scripts/mcp-native-smoke.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Configuration -> native MCP discovery, without authentication or inference. +// node scripts/mcp-native-smoke.mjs codex /absolute/path/to/codex +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { createServer } from "node:http"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +const setup = process.env.T3_SETUP_MODULE || new URL("../docker/setup/mcp.mjs", import.meta.url).href; +const { createMcpService } = await import(setup); +const { withNativeProcess } = await import(process.env.T3_NATIVE_PROCESS_MODULE || new URL("../docker/harness/native-process.mjs", import.meta.url).href); +const [provider, executable] = process.argv.slice(2); +assert.ok(["claude", "codex", "opencode", "cursor"].includes(provider) && path.isAbsolute(executable || ""), "Pass a provider and its absolute executable path."); +const root = await fs.mkdtemp(path.join(os.tmpdir(), "t3-mcp-native-")); +const requests = []; +const answer = (request) => request.method === "initialize" + ? { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "setup-fixture", version: "1.0.0" } } + : request.method === "tools/list" ? { tools: [{ name: "fixture", description: "Disposable test", inputSchema: { type: "object", properties: {} } }] } + : request.method === "resources/list" ? { resources: [] } + : request.method === "resources/templates/list" ? { resourceTemplates: [] } + : request.method === "prompts/list" ? { prompts: [] } : {}; +const http = createServer(async (request, response) => { + if (request.method !== "POST") { response.writeHead(405).end(); return; } + let body = ""; for await (const chunk of request) body += chunk; + const message = JSON.parse(body); + requests.push({ method: message.method, auth: request.headers.authorization, custom: request.headers["x-fixture"] }); + if (message.id === undefined) { response.writeHead(202).end(); return; } + response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message) })); +}); +await new Promise(resolve => http.listen(0, "127.0.0.1", resolve)); +const mock = path.join(root, "server.cjs"); +await fs.writeFile(mock, `const fs = require('node:fs'); const answer = ${answer.toString()}; +require('node:readline').createInterface({input:process.stdin}).on('line', line => { + const request = JSON.parse(line); + fs.appendFileSync(process.argv[2], JSON.stringify({method:request.method,env:process.env.SETUP_FIXTURE_VALUE})+'\\n'); + if(request.id!==undefined) process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:answer(request)})+'\\n'); +});`); +const nativeCommand = async (env, home, args) => (await promisify(execFile)(executable, args, { env, cwd: home, timeout: 20000, maxBuffer: 1024 * 1024 })).stdout; + +async function inventory(env, home, major) { + if (provider === "codex") return withNativeProcess({ executable, env, home, timeoutMs: 20000 }, ["app-server", "--stdio"], async ({ child, nextLine }) => { + const request = async (id, method, params) => { + child.stdin.write(JSON.stringify({ id, method, params }) + "\n"); + for (;;) { const message = JSON.parse(await nextLine()); if (message.id === id) { assert.ok(!message.error); return message.result; } } + }; + await request(1, "initialize", { clientInfo: { name: "setup-native-smoke", version: "1" }, capabilities: { experimentalApi: true } }); + child.stdin.write('{"method":"initialized","params":{}}\n'); + return request(2, "mcpServerStatus/list", {}); + }); + if (provider === "opencode" && major === 2) return withNativeProcess({ executable, home, env: { ...env, OPENCODE_USERNAME: "opencode", OPENCODE_PASSWORD: "fixture" }, timeoutMs: 20000 }, ["serve", "--stdio", "--hostname", "127.0.0.1", "--port", "0"], async ({ nextLine, signal }) => { + const ready = JSON.parse(await nextLine()), base = new URL(ready.url); + assert.ok(base.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(base.hostname)); + let result; + for (let i = 0; i < 120; i++) { + const response = await fetch(new URL("/api/mcp?directory=" + encodeURIComponent(home), base), { signal, headers: { authorization: "Basic " + Buffer.from("opencode:fixture").toString("base64") } }); + assert.equal(response.status, 200); result = await response.json(); + if (result.data?.some(row => row.name === "fixture" && ["connected", "disabled", "failed"].includes(row.status?.status))) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return result; + }); + if (provider === "cursor") { + await nativeCommand(env, home, ["mcp", "enable", "fixture"]); + return nativeCommand(env, home, ["mcp", "list-tools", "fixture"]); + } + return nativeCommand(env, home, ["mcp", provider === "claude" ? "get" : "list", ...(provider === "claude" ? ["fixture"] : [])]); +} + +try { + for (const transport of ["local", "remote"]) { + const home = path.join(root, transport); await fs.mkdir(home); + const env = { ...process.env }; + for (const key of Object.keys(env)) if (/TOKEN|API_KEY|PASSWORD|^OPENCODE|^CLAUDE|^ANTHROPIC|^GEMINI|^T3_/.test(key)) delete env[key]; + Object.assign(env, { HOME: home, CODEX_HOME: path.join(home, ".codex"), CLAUDE_CONFIG_DIR: path.join(home, ".claude"), + XDG_CONFIG_HOME: path.join(home, ".config"), XDG_DATA_HOME: path.join(home, ".data"), XDG_STATE_HOME: path.join(home, ".state"), XDG_CACHE_HOME: path.join(home, ".cache"), + OPENCODE_CONFIG_DIR: path.join(home, ".config/opencode"), OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_DISABLE_PROJECT_CONFIG: "1", DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" }); + const version = (await nativeCommand(env, home, ["--version"])).trim(), major = Number(/(?:^|\s|v)(\d+)\./.exec(version)?.[1]); + if (provider === 'claude' && transport === 'local') { + delete env.CLAUDE_CONFIG_DIR; + const target = path.join(root, 'persisted-claude.json'); + await fs.writeFile(target, '{}\n', {mode:0o600}); + await fs.symlink(target, path.join(home,'.claude.json')); + await nativeCommand(env,home,['mcp','add','--scope','user','writer-fixture','--','/usr/bin/false']); + assert.equal((await fs.lstat(path.join(home,'.claude.json'))).isSymbolicLink(),true,'native Claude writes must preserve state-volume persistence'); + assert.ok(JSON.parse(await fs.readFile(target,'utf8')).mcpServers['writer-fixture']); + } + if (provider === "opencode" && major === 2) { + await fs.mkdir(env.OPENCODE_CONFIG_DIR, { recursive: true }); + await fs.writeFile(path.join(env.OPENCODE_CONFIG_DIR, "opencode.json"), '{"mcp":{"servers":{}}}\n'); + } + const marker = path.join(home, "requests.jsonl"); + const svc = createMcpService({ home, env, io: fs, baseDir: path.join(home, ".t3") }); + const input = transport === "local" + ? { name: "fixture", type: "local", command: [process.execPath, mock, marker], env: { SETUP_FIXTURE_VALUE: "received" } } + : { name: "fixture", type: "remote", url: `http://127.0.0.1:${http.address().port}/mcp`, headers: { Authorization: "Bearer fixture", "X-Fixture": "received" } }; + const created = await svc.create(input); + await svc.setLink(provider, "fixture", true, created.revision, (await svc.list(provider)).revision); + const before = requests.length; + const result = await inventory(env, home, major); + const traffic = transport === "local" ? (await fs.readFile(marker, "utf8")).trim().split("\n").map(JSON.parse) : requests.slice(before); + assert.ok(traffic.some(row => row.method === "initialize"), "native runtime did not initialize MCP"); + if (provider === "codex" || provider === "opencode" && major === 2 || provider === "cursor") assert.ok(traffic.some(row => row.method === "tools/list"), "native runtime did not discover tools"); + if (transport === "local") assert.ok(traffic.every(row => row.env === "received")); + else assert.ok(traffic.every(row => row.auth === "Bearer fixture" && row.custom === "received")); + if (provider === "codex") assert.ok(result.data.some(row => row.tools?.fixture)); + if (provider === "opencode" && major === 2) assert.equal(result.data.find(row => row.name === "fixture").status.status, "connected"); + let currentRevision = created.revision; + if (["codex", "opencode"].includes(provider)) { + await svc.setLink(provider, 'fixture', true, currentRevision, (await svc.list(provider)).revision, false); + const count = transport === 'local' ? (await fs.readFile(marker, 'utf8')).length : requests.length; + await inventory(env, home, major); + assert.equal(transport === 'local' ? (await fs.readFile(marker, 'utf8')).length : requests.length, count, 'a disabled server must not start or connect'); + assert.deepEqual((await svc.list(provider)).disabled, ['fixture']); + await svc.setLink(provider, 'fixture', true, currentRevision, (await svc.list(provider)).revision, true); + const enabled = await inventory(env, home, major); + assert.deepEqual((await svc.list(provider)).disabled, []); + assert.ok(transport === 'local' ? (await fs.readFile(marker, 'utf8')).length > count : requests.length > count, 'turning the copy back on must start it in a fresh native read'); + if (provider === 'codex') assert.ok(enabled.data.some(row => row.tools?.fixture)); + if (provider === 'opencode' && major === 2) assert.equal(enabled.data.find(row => row.name === 'fixture').status.status, 'connected'); + } + await svc.setLink(provider, "fixture", false, currentRevision, (await svc.list(provider)).revision); + assert.equal((await svc.list(provider)).shared[0].linked, false); + console.log(`${provider} ${version}: ${transport} MCP initialize, credentials, unlink${['codex','opencode'].includes(provider) ? ' and native disable' : ''} passed.`); + } +} finally { + http.closeAllConnections(); await new Promise(resolve => http.close(resolve)); + await fs.rm(root, { recursive: true, force: true, maxRetries: 3 }); +} diff --git a/scripts/mcp-ui-audit.js b/scripts/mcp-ui-audit.js new file mode 100644 index 0000000..51a3284 --- /dev/null +++ b/scripts/mcp-ui-audit.js @@ -0,0 +1,303 @@ +#!/usr/bin/env node +// The actual MCP interface against isolated, synthetic settings. All POSTs +// are intercepted; no real agent configuration or MCP server is touched. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +let chromium; try { ({ chromium } = require('playwright')); } catch { ({ chromium } = require('playwright-core')); } +const { statusFor, portsFor, routeConsole, framed, unlock, visit } = require('./lib/console-fixtures.cjs'); +const { createMcpFixtures } = require('./lib/mcp-fixtures.cjs'); +const { checkChipFocus } = require('./lib/chip-focus.cjs'); +const base = process.argv[2] || 'http://127.0.0.1:23804/__setup/', key = process.argv[3] || 'dev'; +const out = process.env.T3_MCP_UI_OUTPUT || '/tmp/t3-mcp-ui-audit'; fs.mkdirSync(out, { recursive: true }); +const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; +(async () => { + const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/usr/bin/chromium', args: ['--no-sandbox'] }); + try { + for (const theme of ['light', 'dark']) for (const variant of ['desktop', 'phone', 'embedded', 'embedded-phone']) { + const embedded = variant.startsWith('embedded'), phone = variant === 'phone' || variant === 'embedded-phone', viewport = phone ? { width: 390, height: 844 } : variant === 'embedded' ? { width: 1198, height: 878 } : { width: 1440, height: 900 }; + const context = await browser.newContext({ viewport, colorScheme: theme, reducedMotion: 'reduce' }); + const page = await context.newPage(), mcp = createMcpFixtures(), errors = [], calls = []; + let failRead = false, failRemoval = false, linkGate = null, readGate = null, parseGate = null, createGate = null, conflictLink = false, conflictSave = false, unreadableStore = false, installedOverride = null; + page.on('pageerror', error => errors.push(error.message)); + const status = () => { const s = statusFor('skills'); if (installedOverride) for (const agent of s.harnesses) agent.installed = installedOverride.includes(agent.id); s.context.mcp = mcp.status(); return s; }; + await routeConsole(page, { status, ports: () => portsFor('ready') }); + await page.route('**/*', async route => { + const req = route.request(), url = new URL(req.url()); + if (!url.pathname.includes('/context/mcp')) return route.fallback(); + const input = req.method() === 'POST' ? req.postDataJSON() : {}; + if (req.method() === 'POST') calls.push({ route: url.pathname.split('/').at(-1), ...input }); + if (url.pathname.endsWith('/parse') && parseGate) await parseGate.promise; + if (url.pathname.endsWith('/create') && createGate) await createGate.promise; + if (url.pathname.endsWith('/link') && conflictLink) { conflictLink = false; return route.fulfill({ status: 409, json: { ok: false, code: 'conflict', error: 'Synthetic settings conflict' } }); } + if (url.pathname.endsWith('/save') && conflictSave) { conflictSave = false; return route.fulfill({ status: 409, json: { ok: false, code: 'conflict', error: 'Synthetic shared conflict' } }); } + if (url.pathname.endsWith('/read') && readGate) await readGate.promise; + if (url.pathname.endsWith('/read') && failRead) return route.fulfill({ status: 500, json: { ok: false, error: 'Synthetic read failure' } }); + if (url.pathname.endsWith('/link') && input.provider === 'codex' && linkGate) await linkGate.promise; + if (url.pathname.endsWith('/link') && !input.linked && input.provider === 'opencode' && failRemoval) return route.fulfill({ status: 503, json: { ok: false, error: 'Synthetic native write failure' } }); + const result = await mcp.respond(req.method(), url, input); + if (unreadableStore && req.method() === 'GET' && url.pathname.endsWith('/context/mcp')) { result.body.ok = false; Object.assign(result.body.shared, { error: 'Synthetic unreadable store', revision: null, count: 0, servers: [] }); } + return route.fulfill({ status: result.status, json: result.body }); + }); + const url = new URL(base); if (embedded) { url.searchParams.set('embed', 't3'); url.searchParams.set('theme', theme); } + const target = embedded ? await framed(page, url.href) : page; + if (!embedded) await page.goto(url.href); + await unlock(target, key); await visit(target, page, 'mcp'); + assert.equal(await target.evaluate(() => document.documentElement.getAttribute('data-embed')), embedded ? 't3' : null, 'The authenticated page must retain its actual embedding mode'); + if (phone) assert.equal(await target.getByRole(embedded ? 'button' : 'link', { name: embedded ? 'Close setup' : 'Close MCP servers', exact: true }).isVisible(), true, 'Phone close uses the applied embedding mode'); + await target.locator('#mcp-search').waitFor(); + await target.locator('[data-key="mcp-shared:docs"]').waitFor({ state: 'attached' }); + await target.evaluate(() => { location.hash = 'instructions/mcp'; }); + await target.waitForFunction(() => location.hash === '#mcp'); + await target.evaluate(() => { location.hash = 'overview'; }); + await target.locator('#page-overview:not([hidden])').waitFor(); + await page.keyboard.press('g'); await page.keyboard.press('m'); + await target.locator('#page-mcp:not([hidden])').waitFor(); + await page.keyboard.press('/'); + assert.equal(await target.evaluate(() => document.activeElement.id), 'mcp-search'); + await page.keyboard.press('Tab'); + const screenshot = async name => { await page.screenshot({ path: path.join(out, `${variant}-${theme}-${name}.png`) }); }; + const noOverflow = async () => assert.equal(await target.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, 'Document must not overflow'); + const openServer = async name => { await target.locator(phone ? `.tc-sk-phone-list [data-cmd="mcp.view"][data-id="${name}"]` : `.tc-mx [data-cmd="mcp.view"][data-id="${name}"]`).click(); await target.locator('.tc-mcp-inspector .tc-mcp-connection').waitFor(); }; + const closeInspector = () => target.getByRole('button', { name: 'Close server inspector', exact: true }).click(); + await noOverflow(); await screenshot('library'); + if (phone) { + await checkChipFocus(page, target.locator('.tc-mcp-library .tc-sk-agent-chips'), screenshot); + const add = await target.locator('.tc-sk-phone-actions button').boundingBox(); + assert.ok(add && add.y + add.height <= viewport.height && add.y > 0, 'Phone Add stays reachable'); + assert.equal(await target.locator('.tc-tabbar').isVisible(), false); + } else { + assert.equal(await target.locator('.tc-mcp-table [role="columnheader"]').count(), 6); + const columnButton = target.locator('[data-cmd="mcp.columns"]'); + assert.equal(await target.locator('.tc-mcp-extra').count(), 0, 'No trailing +2 control'); + await columnButton.click(); + assert.equal(await target.getByRole('menuitemradio', { name: /^Installed agents/ }).getAttribute('aria-checked'), 'true'); + await page.keyboard.press('Escape'); + assert.equal(await columnButton.evaluate(element => element === document.activeElement), true, 'Column menu restores keyboard focus'); + await columnButton.click(); + await target.getByRole('menuitemradio', { name: /^All agents/ }).click(); + await target.waitForFunction(() => document.querySelectorAll('.tc-mcp-table [role="columnheader"]').length === 8); + assert.equal(await columnButton.innerText(), 'All agents'); + assert.equal(await target.locator('.tc-mcp-table .tc-mx-agent[data-uninstalled]').count(), 2); + assert.equal(await target.locator('.tc-mcp-table .tc-mx-agent').filter({ hasText: 'Not managed here' }).count(), 2); + const measureColumns = () => target.locator('.tc-mcp-table .tc-mx').evaluate(table => { + const right = table.getBoundingClientRect().right; + return { + overflow: table.scrollWidth - table.clientWidth, right, + rows: [...table.querySelectorAll('.tc-mx-head,.tc-mx-row')].map(row => ({ right: row.getBoundingClientRect().right, cells: row.children.length, last: row.lastElementChild.getBoundingClientRect().right, grid: getComputedStyle(row).gridTemplateColumns })), + overflowing: [...table.querySelectorAll('*')].filter(element => element.getBoundingClientRect().right > right + 1).slice(0, 10).map(element => ({ class: element.getAttribute('class'), right: element.getBoundingClientRect().right, width: element.getBoundingClientRect().width })), + }; + }); + // New DOM columns can land a frame before container-query sizing. + // Wait for actual containment, as in the Skills audit; a persistent + // overflow still fails with measurements and a screenshot. + await target.waitForFunction(() => { + const table = document.querySelector('.tc-mcp-table .tc-mx'); + return table.scrollWidth <= table.clientWidth + 1; + }, null, { timeout: 5000 }).catch(async error => { + await screenshot('all-agent-columns-containment-failure'); + console.error(`${variant} ${theme}: expanded column layout: ${JSON.stringify(await measureColumns())}`); + throw error; + }); + const containment = await measureColumns(); + if (containment.overflow > 1) await screenshot('all-agent-columns-containment-failure'); + assert.ok(containment.overflow <= 1, `${variant} ${theme}: expanded columns fit their table background: ${JSON.stringify(containment)}`); + assert.ok(containment.rows.every(row => row.cells === 8 && Math.abs(row.right - containment.right) < 1 && Math.abs(row.right - row.last - 8) < 1), `${variant} ${theme}: every header and row uses eight columns with the same end inset: ${JSON.stringify(containment)}`); + const sticky = await target.locator('.tc-mcp-table .tc-mx-scroll').evaluate(scroll => { + scroll.scrollLeft = scroll.scrollWidth - scroll.clientWidth; + const left = scroll.getBoundingClientRect().left, header = scroll.querySelector('.tc-mx-head'), label = scroll.querySelector('.tc-mx-head-label'), group = scroll.querySelector('.tc-mx-group').getBoundingClientRect(); + return { first: scroll.querySelector('.tc-mx-skill').getBoundingClientRect().left - left, button: scroll.querySelector('[data-cmd="mcp.columns"]').getBoundingClientRect().left - left, width: scroll.clientWidth, headerCover: header.getBoundingClientRect().height - label.getBoundingClientRect().height, groupLeft: group.left - left, groupWidth: group.width }; + }); + assert.ok(Math.abs(sticky.first) < 1 && sticky.button >= 0 && sticky.button < sticky.width, 'Names and column selector stay visible at the far scroll edge'); + assert.ok(sticky.headerCover <= 1 && Math.abs(sticky.groupLeft) < 1 && Math.abs(sticky.groupWidth - sticky.width) < 1, 'Pinned headings cover scrolling text and keep group context in view'); + await screenshot('all-agent-columns'); + const firstPolicy = target.locator('.tc-mcp-table .tc-mx-row .tc-cellwrap button').first(); + await firstPolicy.focus(); + assert.ok(await firstPolicy.evaluate(button => button.getBoundingClientRect().left >= button.closest('.tc-mx-row').querySelector('.tc-mx-skill').getBoundingClientRect().right - 1), 'Focused controls scroll clear of the pinned server column'); + await columnButton.click(); + assert.equal(await target.getByRole('menuitemradio', { name: /^All agents/ }).getAttribute('aria-checked'), 'true'); + await screenshot('agent-columns-menu'); + assert.match(await target.getByRole('menu').innerText(), /Muse Code/); + await target.getByRole('menuitemradio', { name: /^Installed agents/ }).click(); + await target.waitForFunction(() => document.querySelectorAll('.tc-mcp-table [role="columnheader"]').length === 6); + assert.equal(await target.locator('.tc-mcp-table .tc-mx-scroll').evaluate(scroll => scroll.scrollLeft), 0); + assert.equal(await columnButton.evaluate(element => element === document.activeElement), true, 'Visibility choice restores focus'); + assert.equal(calls.length, 0, 'Column visibility never writes settings'); + // The selected menu survives an unrelated /status refresh. + await target.getByRole('button', { name: 'docs in Codex: on', exact: true }).click(); + await target.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))); + assert.equal(await target.getByRole('menu').isVisible(), true); + await screenshot('cell-menu'); + linkGate = deferred(); + await target.getByRole('menuitemradio', { name: /^Off/ }).click(); + await target.locator('.tc-mcp-table [role="status"]').waitFor(); + assert.equal(await target.getByRole('button', { name: 'Share filesystem with Codex', exact: true }).isEnabled(), false); + assert.equal(await target.getByRole('button', { name: 'docs in OpenCode: on', exact: true }).isEnabled(), true); + linkGate.resolve(); linkGate = null; + await target.getByRole('button', { name: 'docs in Codex: off', exact: true }).waitFor(); + } + await openServer('docs'); + assert.equal(await target.locator('.tc-mcp-inspector').getByText('Bearer synthetic-shared-token', { exact: true }).count(), 0, 'No secret DOM before reveal'); + if (phone) await target.getByRole('button', { name: 'Review', exact: true }).click(); + await target.locator('.tc-diff-vals').waitFor(); + assert.equal(await target.locator('.tc-diff').innerText().then(text => /synthetic-.*token/.test(text)), false); + await target.getByRole('button', { name: 'Reveal Authorization', exact: true }).click(); + assert.equal(await target.locator('.tc-mcp-secret-value').innerText(), 'Bearer synthetic-shared-token'); + await target.getByRole('button', { name: 'Hide Authorization', exact: true }).click(); + await screenshot('inspector'); + if (phone) { + await target.locator('[data-mcp-policy="codex"]').selectOption('off'); + await target.locator('[data-mcp-policy="codex"]').waitFor(); + assert.equal(await target.locator('[data-mcp-policy="codex"]').inputValue(), 'off'); + } + conflictLink = true; + await target.locator('[data-mcp-policy="codex"]').selectOption('on'); + await target.locator('.tc-mcp-inspector').getByText('Settings changed elsewhere. Showing the current state.', { exact: true }).waitFor(); + assert.equal(await target.locator('[data-mcp-policy="codex"]').inputValue(), 'off'); + await target.getByRole('button', { name: 'Edit server…', exact: true }).click(); + await target.locator('#mcp-url').waitFor(); + await target.locator('#mcp-url').fill('https://docs.example.test/v2/mcp'); + await target.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))); + assert.equal(await target.locator('#mcp-url').inputValue(), 'https://docs.example.test/v2/mcp'); + await screenshot('edit'); + if (variant === 'desktop') { + await page.setViewportSize({ width: 390, height: 844 }); + await target.locator('.tc-wizard-rail-toggle').waitFor(); + await page.setViewportSize(viewport); + await target.locator('.tc-mcp-editor .tc-agentpick').waitFor(); + assert.equal(await target.locator('#mcp-url').inputValue(), 'https://docs.example.test/v2/mcp'); + } + await page.keyboard.press('Escape'); + await target.getByRole('button', { name: 'Cancel', exact: true }).last().click(); // keep draft + assert.equal(await target.locator('#mcp-url').inputValue(), 'https://docs.example.test/v2/mcp'); + conflictSave = true; + await target.getByRole('button', { name: 'Save and update 2 agents', exact: true }).click(); + await target.getByRole('button', { name: 'Reload current settings', exact: true }).waitFor(); + assert.equal(await target.locator('#mcp-url').inputValue(), 'https://docs.example.test/v2/mcp'); + assert.equal(await target.getByRole('button', { name: 'Save and update 2 agents', exact: true }).isEnabled(), false); + await target.getByRole('button', { name: 'Reload current settings', exact: true }).click(); + await target.getByRole('button', { name: 'Discard changes', exact: true }).click(); + await target.waitForFunction(() => document.querySelector('#mcp-url')?.value === 'https://docs.example.test/mcp'); + await target.locator('#mcp-url').fill('https://docs.example.test/v2/mcp'); + await target.getByRole('button', { name: 'Save and update 2 agents', exact: true }).click(); + await target.locator('.tc-mcp-editor').waitFor({ state: 'detached' }); + assert.equal(mcp.status().providers.find(p => p.provider === 'codex').disabled.includes('docs'), true, 'Editing preserves Off'); + assert.equal(mcp.status().providers.find(p => p.provider === 'claude').diverged.includes('docs'), true, 'Differing copies untouched by default'); + + await target.locator(phone ? '.tc-sk-phone-actions [data-cmd="mcp.add"]' : '.tc-sk-desktop-actions [data-cmd="mcp.add"]').click(); + parseGate = deferred(); + await target.locator('#mcp-snippet').fill('{"mcpServers":{"issues":{"command":"npx","args":["-y","@acme/issues-mcp",""],"env":{"ISSUES_TOKEN":""}}}}'); + await target.locator('#mcp-name').waitFor(); + await target.locator('.tc-paste-meta').filter({ hasText: 'Reading snippet…' }).waitFor(); + await target.locator('#mcp-name').fill('manual-draft'); + parseGate.resolve(); parseGate = null; + await target.getByRole('button', { name: 'Use snippet', exact: true }).waitFor(); + assert.equal(await target.locator('#mcp-name').inputValue(), 'manual-draft', 'Late parse must not overwrite manual edits'); + await target.getByRole('button', { name: 'Use snippet', exact: true }).click(); + await target.waitForFunction(() => document.querySelector('#mcp-name')?.value === 'issues'); + assert.equal(await target.locator('#mcp-arg-3').inputValue(), '', 'Empty argument stays lossless'); + assert.equal(await target.getByText(/still holds the snippet’s placeholder/).isVisible(), true); + await target.locator('[data-mcp-editor="type"][data-type="remote"]').click(); + await target.locator('#mcp-url').fill('https://transport-draft.test/mcp'); + await target.locator('[data-mcp-editor="type"][data-type="local"]').click(); + assert.equal(await target.locator('#mcp-arg-3').inputValue(), ''); + assert.equal(await target.locator('[data-mcp-field="pair-value"]').inputValue(), ''); + await target.locator('[data-mcp-editor="type"][data-type="remote"]').click(); + assert.equal(await target.locator('#mcp-url').inputValue(), 'https://transport-draft.test/mcp'); + await target.locator('[data-mcp-editor="type"][data-type="local"]').click(); + await screenshot('add-snippet'); + const beforeCreates = calls.filter(c => c.route === 'create').length; + createGate = deferred(); + await target.getByRole('button', { name: 'Add to 4 agents', exact: true }).click(); + await target.getByRole('button', { name: 'Adding', exact: true }).waitFor(); + assert.equal(await target.getByRole('button', { name: 'Cancel', exact: true }).isEnabled(), false); + await page.keyboard.press('Control+Enter'); await page.keyboard.press('Control+Enter'); + createGate.resolve(); createGate = null; + await target.locator('.tc-mcp-editor').waitFor({ state: 'detached' }); + assert.equal(calls.filter(c => c.route === 'create').length, beforeCreates + 1); + assert.deepEqual(calls.filter(c => c.route === 'link' && c.name === 'issues').map(c => c.provider).sort(), ['claude', 'codex', 'cursor', 'opencode']); + + await target.locator(phone ? '.tc-sk-phone-list [data-cmd="mcp.share"][data-id="github"]' : '.tc-mx [data-cmd="mcp.share"][data-id="github"]').first().click(); + await target.locator('[data-mcp-consent="opencode"]').waitFor(); + assert.equal(await target.getByRole('button', { name: 'Share and use in 4 agents', exact: true }).isEnabled(), false); + await target.locator('[data-mcp-consent="opencode"]').check(); + await screenshot('share-consent'); + await target.getByRole('button', { name: 'Share and use in 4 agents', exact: true }).click(); + await target.locator('.tc-mcp-editor').waitFor({ state: 'detached' }); + assert.equal(mcp.status().ownGroups.some(g => g.name === 'github'), false); + assert.equal(mcp.status().shared.servers.some(s => s.name === 'github'), true); + + // One source needs a summary card, not a vacant radio track. + await target.locator(phone ? '.tc-sk-phone-list [data-cmd="mcp.share"][data-id="filesystem"]' : '.tc-mx [data-cmd="mcp.share"][data-id="filesystem"]').first().click(); + await target.locator('.tc-choice--summary').waitFor(); + assert.ok((await target.locator('.tc-choice-head').boundingBox()).width > 100); + await target.getByRole('button', { name: 'Cancel', exact: true }).click(); + + await openServer('docs'); + if (phone) { await target.getByRole('button', { name: 'Server actions', exact: true }).click(); await target.getByRole('menuitem', { name: 'Remove…', exact: true }).click(); } + else await target.getByRole('button', { name: 'Remove…', exact: true }).click(); + failRemoval = true; + await target.locator('[data-mcp-remove="confirm"]').click(); + await target.locator('.tc-mcp-remove [role="alert"]').waitFor(); + assert.equal(mcp.status().shared.servers.some(s => s.name === 'docs'), true, 'Partial remove retains shared definition'); + await screenshot('remove-partial'); + failRemoval = false; + await target.locator('[data-mcp-remove="confirm"]').click(); + await target.locator('.tc-mcp-remove').waitFor({ state: 'detached' }); + assert.equal(mcp.status().shared.servers.some(s => s.name === 'docs'), false); + + await openServer('database'); + await target.locator('[data-mcp-policy="codex"]').selectOption('remove'); + await target.waitForFunction(() => getComputedStyle(document.getElementById('toaster')).visibility === 'hidden'); + await target.locator('.tc-mcp-inspector [data-mcp-inspect="undo"]').click(); + await target.waitForFunction(() => document.querySelector('[data-mcp-policy="codex"]')?.value === 'off'); + assert.equal(mcp.status().providers.find(p => p.provider === 'codex').disabled.includes('database'), true, 'Undo preserves Off'); + await closeInspector(); + + failRead = true; await target.locator(phone ? '.tc-sk-phone-list [data-cmd="mcp.view"][data-id="issues"]' : '.tc-mx [data-cmd="mcp.view"][data-id="issues"]').click(); + await target.locator('.tc-mcp-inspector [role="alert"]').waitFor(); + failRead = false; await target.getByRole('button', { name: 'Try again', exact: true }).click(); + await target.locator('.tc-mcp-inspector .tc-mcp-connection').waitFor(); await closeInspector(); + readGate = deferred(); + await target.locator(phone ? '.tc-sk-phone-list [data-cmd="mcp.view"][data-id="issues"]' : '.tc-mx [data-cmd="mcp.view"][data-id="issues"]').click(); + await closeInspector(); readGate.resolve(); readGate = null; + await target.locator('.tc-mcp-inspector').waitFor({ state: 'detached' }); + unreadableStore = true; + await target.getByRole('button', { name: 'Rescan', exact: true }).click(); + await target.getByText('Shared servers unavailable', { exact: true }).waitFor(); + assert.equal(await target.locator(phone ? '.tc-sk-phone-list [data-id="filesystem"]' : '.tc-mx [data-id="filesystem"]').first().isVisible(), true, 'Readable native entries survive an unreadable shared store'); + assert.equal(await target.locator(phone ? '.tc-sk-phone-actions [data-cmd="mcp.add"]' : '.tc-sk-desktop-actions [data-cmd="mcp.add"]').isEnabled(), false); + await screenshot('unreadable-store'); + if (embedded && phone) { + await page.evaluate(() => { window.mcpMessages = []; window.addEventListener('message', event => window.mcpMessages.push(event.data)); }); + await target.getByRole('button', { name: 'Close setup', exact: true }).click(); + await page.waitForFunction(() => window.mcpMessages.some(message => message?.source === 't3-setup' && message.type === 'close')); + } + if (variant === 'desktop' && theme === 'light') { + unreadableStore = false; + const reload = async ids => { + installedOverride = ids; + await target.evaluate(() => location.reload()); + await target.locator('.tc-mcp-table .tc-mx-row').first().waitFor(); + await target.waitForFunction(count => document.querySelectorAll('.tc-mcp-table .tc-mx-agent').length === count, ids.length); + }; + await reload(['claude', 'codex', 'opencode', 'grok']); + await target.locator('[data-cmd="mcp.columns"]').click(); + await target.getByRole('menuitemradio', { name: /^All agents/ }).click(); + const cursor = target.locator('[data-key="mcp-shared:issues-cursor"]'); + assert.equal(await cursor.locator('button').count(), 0, 'An uninstalled managed agent has no mutation control'); + assert.equal(await cursor.innerText(), 'On', 'Its saved state stays visible'); + await reload([]); + await target.locator('[data-cmd="mcp.columns"]').click(); + await target.getByRole('menuitemradio', { name: /^All agents/ }).click(); + assert.equal(await target.locator('.tc-mcp-table .tc-cellwrap button').count(), 0, 'Zero installed agents cannot mutate native settings'); + await reload(['claude', 'codex', 'opencode', 'cursor', 'grok', 'muse', 'antigravity']); + assert.equal(await target.locator('[data-cmd="mcp.columns"]').count(), 0, 'No redundant choice when all agents are installed'); + assert.equal(await target.locator('[data-key="mcp-shared:issues-muse"] button').count(), 0, 'Installing an unsupported agent does not enable management'); + } + await noOverflow(); assert.deepEqual(errors, [], 'No browser exceptions'); + console.log(`${variant} ${theme}: matrix, secrets, per-agent off, draft/poll guards, snippet, consent, partial removal, failed/late reads passed`); + await context.close(); + } + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/skills-ui-audit.js b/scripts/skills-ui-audit.js index 41fd250..b2e5de1 100644 --- a/scripts/skills-ui-audit.js +++ b/scripts/skills-ui-audit.js @@ -8,6 +8,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { statusFor, portsFor, fixture, framed } = require('./lib/console-fixtures.cjs'); const { createSkillsFixtures } = require('./lib/skills-fixtures.cjs'); +const { checkChipFocus } = require('./lib/chip-focus.cjs'); const base = process.argv[2] || 'http://127.0.0.1:23780/__setup/'; const key = process.argv[3] || 'dev'; const output = process.env.T3_SKILLS_UI_OUTPUT || '/tmp/t3-skills-ui-audit'; @@ -177,6 +178,7 @@ const checkSelect = async (page, select, theme) => { await target.locator('[data-cmd="skills.filter"]').click(); await target.getByRole('menuitemradio', { name: 'Any agent', exact: true }).click(); } else { + await checkChipFocus(page, target.locator('.tc-sk-agent-chips'), shot); await target.locator('[data-cmd="skills.agent"][data-provider=codex]').click(); assert.equal(await target.locator('.tc-mx-row').count(), 1); await target.locator('[data-cmd="skills.agent"][data-provider=""]').click(); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index ede4890..7ee564e 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -650,7 +650,9 @@ skills_editor_works() { [ "$HAS_BROWSER" -eq 1 ] || return 0 docker cp scripts/skills-ui-audit.js "$NAME:/tmp/skills-ui-audit.js" >/dev/null 2>&1 || return 1 docker exec "$NAME" mkdir -p /tmp/lib /tmp/fixtures || return 1 + docker cp scripts/lib/mcp-fixtures.cjs "$NAME:/tmp/lib/mcp-fixtures.cjs" >/dev/null 2>&1 || return 1 docker cp scripts/lib/console-fixtures.cjs "$NAME:/tmp/lib/console-fixtures.cjs" >/dev/null 2>&1 || return 1 + docker cp scripts/lib/chip-focus.cjs "$NAME:/tmp/lib/chip-focus.cjs" >/dev/null 2>&1 || return 1 docker cp scripts/lib/skills-fixtures.cjs "$NAME:/tmp/lib/skills-fixtures.cjs" >/dev/null 2>&1 || return 1 docker cp scripts/fixtures/console "$NAME:/tmp/fixtures/" >/dev/null 2>&1 || return 1 docker exec -e CHROME_PATH=/usr/bin/chromium \ @@ -692,6 +694,35 @@ context_instructions_work() { } check "Context authenticates saves and reports provider links" context_instructions_work +mcp_servers_list_without_secrets() { + docker exec "$NAME" curl -fsS --max-time 25 -H "x-t3-setup-key: $SETUP_KEY" \ + http://127.0.0.1:3774/__setup/context/mcp | python3 -c " +import json, sys +body = json.load(sys.stdin) +shared = body['shared'] +assert isinstance(shared.get('servers'), list), shared +for server in shared['servers']: + assert 'env' not in server and 'headers' not in server, server +providers = {row['provider'] for row in body['providers']} +assert {'claude', 'codex', 'opencode'} <= providers, providers +" +} +check "shared MCP servers list without secrets" mcp_servers_list_without_secrets + +mcp_editor_works() { + [ "$HAS_BROWSER" -eq 1 ] || return 0 + docker exec "$NAME" mkdir -p /tmp/t3-mcp-audit/lib /tmp/t3-mcp-audit/fixtures || return 1 + docker cp scripts/lib/mcp-fixtures.cjs "$NAME:/tmp/t3-mcp-audit/lib/mcp-fixtures.cjs" >/dev/null 2>&1 || return 1 + docker cp scripts/mcp-ui-audit.js "$NAME:/tmp/t3-mcp-audit/mcp-ui-audit.js" >/dev/null 2>&1 || return 1 + docker cp scripts/lib/console-fixtures.cjs "$NAME:/tmp/t3-mcp-audit/lib/console-fixtures.cjs" >/dev/null 2>&1 || return 1 + docker cp scripts/lib/chip-focus.cjs "$NAME:/tmp/t3-mcp-audit/lib/chip-focus.cjs" >/dev/null 2>&1 || return 1 + docker cp scripts/lib/skills-fixtures.cjs "$NAME:/tmp/t3-mcp-audit/lib/skills-fixtures.cjs" >/dev/null 2>&1 || return 1 + docker cp scripts/fixtures/console "$NAME:/tmp/t3-mcp-audit/fixtures/console" >/dev/null 2>&1 || return 1 + docker exec -e T3_SETUP_MODULE=/opt/t3-setup/mcp.mjs -e CHROME_PATH=/usr/bin/chromium -e NODE_PATH=/opt/t3/node_modules \ + "$NAME" node /tmp/t3-mcp-audit/mcp-ui-audit.js "http://127.0.0.1:3774/" "$SETUP_KEY" >>"$UI_AUDIT_LOG" 2>&1 +} +check "MCP controls refresh immediately and preserve editor revisions and values" mcp_editor_works + hello_says_only_what_it_may() { local stranger member stranger="$(docker exec "$NAME" curl -fsS --max-time 25 http://127.0.0.1:3774/__setup/hello)" || return 1 @@ -832,6 +863,19 @@ native_skill_controls_work() { } check "native skill controls work with the installed Claude, Codex and OpenCode" "native_skill_controls_work >>\"$UI_AUDIT_LOG\" 2>&1" +native_mcp_controls_work() { + local id executable + docker cp scripts/mcp-native-smoke.mjs "$NAME:/tmp/mcp-native-smoke.mjs" >/dev/null 2>&1 || return 1 + for id in claude codex opencode cursor; do + executable="$(status_json | jq -r --arg id "$id" '.harnesses[] | select(.id == $id) | .executable')" + [ -n "$executable" ] && [ "$executable" != null ] || return 1 + docker exec -u t3 -e T3_SETUP_MODULE=/opt/t3-setup/mcp.mjs \ + -e T3_NATIVE_PROCESS_MODULE=/opt/t3-harness/native-process.mjs \ + "$NAME" node /tmp/mcp-native-smoke.mjs "$id" "$executable" || return 1 + done +} +check "native MCP local and HTTP settings work in Claude, Codex, OpenCode and Cursor" "native_mcp_controls_work >>\"$UI_AUDIT_LOG\" 2>&1" + # T3 learns about each agent through its settings file, as soon as the agent # lands. Read it the way T3 does: each points at the agent's launcher, which # runs the managed executable (and hands T3's own Update to the manager). diff --git a/scripts/ui-audit.js b/scripts/ui-audit.js index 4b78dd2..9627a2c 100755 --- a/scripts/ui-audit.js +++ b/scripts/ui-audit.js @@ -351,7 +351,11 @@ const audit = () => { await page.click(".tc-layer .tc-menu-item:has-text('Rename')"); }; const STATES = [ - ...ROUTES.map((route) => [route, async (page) => { + ['MCP servers', async (page) => { + await page.evaluate(() => { location.hash = 'mcp'; }); + await page.waitForSelector('#mcp-library .tc-mcp-table:visible, #mcp-library .tc-sk-phone-list:visible, #mcp-library .tc-empty:visible'); + }], + ...ROUTES.filter(route => route !== 'mcp').map((route) => [route, async (page) => { await page.evaluate((r) => { location.hash = r; }, route); await page.waitForSelector(`#page-${route}:not([hidden]) .tc-section, #page-${route}:not([hidden]) .tc-group`, { timeout: 60000 }); }]), diff --git a/tests/context-api.test.mjs b/tests/context-api.test.mjs index 659b49f..f2206cd 100644 --- a/tests/context-api.test.mjs +++ b/tests/context-api.test.mjs @@ -25,12 +25,16 @@ test("global instruction API discovers existing files and changes them only on e MISE_CONFIG_DIR: path.join(config, "mise"), T3_SETUP_PORT: String(port), T3_SETUP_KEY: "fixture-key", T3_SETUP_KEY_SOURCE: "env", T3_INFRA_LAUNCHER: "/bin/false", T3CODE_PORT: "1", }, stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"); let logs = ""; child.stdout.on("data", (chunk) => { logs += chunk; }); child.stderr.on("data", (chunk) => { logs += chunk; }); t.after(async () => { - if (child.exitCode === null) { const exit = once(child, "exit"); child.kill(); await exit; } - await fs.rm(root, { recursive: true, force: true }); + if (child.exitCode === null && child.signalCode === null) child.kill(); + await closed; + // Native status probes can finish filesystem work during shutdown. Drain + // their stdio and allow bounded cleanup retries, as other API fixtures do. + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); }); const base = `http://127.0.0.1:${port}`; let ready = false; diff --git a/tests/fixtures/mcp-snippets.json b/tests/fixtures/mcp-snippets.json new file mode 100644 index 0000000..930802f --- /dev/null +++ b/tests/fixtures/mcp-snippets.json @@ -0,0 +1,106 @@ +{ + "about": "Expected results for POST /context/mcp/parse. Each case is a node:test case. 'servers' use the shared store's canonical shape (validateMcpServer in docker/setup/mcp/store.mjs), without 'enabled'. 'placeholders' lists fields whose values still look like placeholders. Names are suggestions the user can edit.", + "rules": { + "order": "Try TOML first when any line starts a [mcp_servers.] table (Codex snippets start with [, like a JSON array). Then JSON when the text starts with { or [. Then a 'claude mcp add' command, then a single http(s) URL, then a command line. Text that starts with { or [ and is neither TOML nor valid JSON is a JSON error, never a command line.", + "jsonShapes": "mcpServers. (Claude Code, Cursor, .mcp.json) through fromClaudeEntry / fromCursorEntry; mcp. (OpenCode 1) and mcp.servers. (OpenCode 2) through fromOpenCodeEntry; a bare { command, args, env } or { url, headers } object with no name.", + "lexer": "POSIX-style: single quotes literal, double quotes with backslash escapes, backslash outside quotes escapes the next character. No expansion of $VARS, globs or ~. Nothing is executed.", + "names": "Config formats: the key. Command lines: the package after npx/bunx/uvx/pnpm dlx (the first argument that isn't an option), the image after docker run (the first argument after run that is neither an option nor the value of -e, --env, -v, --volume, -p, --publish, --name, --network, -w or --workdir; then its last path segment), else the executable's base name. Then lowercase, drop @scope/, @version and :tag, drop a leading mcp-server- or server- and a trailing -mcp-server, -mcp or -server, replace anything outside [a-z0-9-] with -, collapse and trim hyphens. URLs: the first host label that isn't www, mcp or api, excluding the last label. Fallback: 'server'.", + "placeholders": "A value containing <...>, YOUR_ or your-, or equal to xxx, ..., changeme, replace-me or todo (case-insensitive). ${VAR} references are not placeholders; they get a note that each agent resolves them its own way.", + "rejections": "type sse, or a URL ending in /sse with no transport: 'SSE-only servers aren't portable. Use the server's Streamable HTTP address.' URL user:password: 'Put credentials in a header, not in the URL.' More than 64 KiB: 'That's too long to be a server snippet.'" + }, + "cases": [ + { + "id": "claude-json-local", + "input": "{ \"mcpServers\": { \"issues\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@acme/issues-mcp\", \"--read-only\"],\n \"env\": { \"ISSUES_TOKEN\": \"\" }\n} } }", + "expect": { "format": "claude-json", "servers": [ { "name": "issues", "type": "local", "command": ["npx", "-y", "@acme/issues-mcp", "--read-only"], "env": { "ISSUES_TOKEN": "" } } ], "placeholders": ["env.ISSUES_TOKEN"] } + }, + { + "id": "cursor-json-url-only", + "input": "{\"mcpServers\":{\"docs\":{\"url\":\"https://docs.example.test/mcp\",\"headers\":{\"Authorization\":\"Bearer YOUR_TOKEN\"}}}}", + "expect": { "format": "claude-json", "servers": [ { "name": "docs", "type": "remote", "url": "https://docs.example.test/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } ], "placeholders": ["header.Authorization"] } + }, + { + "id": "claude-json-http", + "input": "{\"mcpServers\":{\"docs\":{\"type\":\"http\",\"url\":\"https://docs.example.test/mcp\"}}}", + "expect": { "format": "claude-json", "servers": [ { "name": "docs", "type": "remote", "url": "https://docs.example.test/mcp", "headers": {} } ], "placeholders": [] } + }, + { + "id": "claude-json-several", + "input": "{\"mcpServers\":{\"alpha\":{\"command\":\"alpha-mcp\"},\"beta\":{\"type\":\"http\",\"url\":\"https://beta.example.test/mcp\"}}}", + "expect": { "format": "claude-json", "servers": [ { "name": "alpha", "type": "local", "command": ["alpha-mcp"], "env": {} }, { "name": "beta", "type": "remote", "url": "https://beta.example.test/mcp", "headers": {} } ], "placeholders": [] }, + "ui": "Show 'Found 2 servers' with a choice; fill the form from the chosen one. Add the others afterwards." + }, + { + "id": "codex-toml-local", + "input": "[mcp_servers.database]\ncommand = \"database-mcp\"\nargs = [\"--readonly\"]\nenv = { DATABASE_URL = \"postgres://localhost/app\" }\n", + "expect": { "format": "codex-toml", "servers": [ { "name": "database", "type": "local", "command": ["database-mcp", "--readonly"], "env": { "DATABASE_URL": "postgres://localhost/app" } } ], "placeholders": [] } + }, + { + "id": "codex-toml-remote", + "input": "[mcp_servers.docs]\nurl = \"https://docs.example.test/mcp\"\nhttp_headers = { Authorization = \"Bearer \" }\n", + "expect": { "format": "codex-toml", "servers": [ { "name": "docs", "type": "remote", "url": "https://docs.example.test/mcp", "headers": { "Authorization": "Bearer " } } ], "placeholders": ["header.Authorization"] } + }, + { + "id": "opencode-v1", + "input": "{\"mcp\":{\"filesystem\":{\"type\":\"local\",\"command\":[\"npx\",\"-y\",\"@modelcontextprotocol/server-filesystem\",\"/workspace\"],\"enabled\":true}}}", + "expect": { "format": "opencode-json", "servers": [ { "name": "filesystem", "type": "local", "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"], "env": {} } ], "placeholders": [] } + }, + { + "id": "opencode-v2", + "input": "{\"mcp\":{\"servers\":{\"docs\":{\"type\":\"remote\",\"url\":\"https://docs.example.test/mcp\",\"headers\":{\"Authorization\":\"Bearer abc\"}}}}}", + "expect": { "format": "opencode-json", "servers": [ { "name": "docs", "type": "remote", "url": "https://docs.example.test/mcp", "headers": { "Authorization": "Bearer abc" } } ], "placeholders": [] } + }, + { + "id": "claude-mcp-add-http", + "input": "claude mcp add --transport http docs https://docs.example.test/mcp --header \"Authorization: Bearer YOUR_TOKEN\"", + "expect": { "format": "claude-mcp-add", "servers": [ { "name": "docs", "type": "remote", "url": "https://docs.example.test/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } ], "placeholders": ["header.Authorization"] } + }, + { + "id": "claude-mcp-add-stdio", + "input": "claude mcp add --env ISSUES_TOKEN=\"\" issues -- npx -y @acme/issues-mcp", + "expect": { "format": "claude-mcp-add", "servers": [ { "name": "issues", "type": "local", "command": ["npx", "-y", "@acme/issues-mcp"], "env": { "ISSUES_TOKEN": "" } } ], "placeholders": ["env.ISSUES_TOKEN"] } + }, + { + "id": "command-line", + "input": "npx -y @acme/issues-mcp --read-only", + "expect": { "format": "command", "servers": [ { "name": "issues", "type": "local", "command": ["npx", "-y", "@acme/issues-mcp", "--read-only"], "env": {} } ], "placeholders": [] } + }, + { + "id": "command-line-quoting", + "input": "notes-mcp --root \"/work space\" --label ''", + "expect": { "format": "command", "servers": [ { "name": "notes", "type": "local", "command": ["notes-mcp", "--root", "/work space", "--label", ""], "env": {} } ], "placeholders": [] }, + "ui": "An empty argument or one with a newline opens the command in one-per-line mode." + }, + { + "id": "docker-image", + "input": "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server", + "expect": { "format": "command", "servers": [ { "name": "github", "type": "local", "command": ["docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], "env": {} } ], "placeholders": [] }, + "ui": "-e NAME with no value passes the variable through from the environment. Suggest adding GITHUB_PERSONAL_ACCESS_TOKEN as an environment row." + }, + { + "id": "uvx-prefix", + "input": "uvx mcp-server-fetch", + "expect": { "format": "command", "servers": [ { "name": "fetch", "type": "local", "command": ["uvx", "mcp-server-fetch"], "env": {} } ], "placeholders": [] } + }, + { + "id": "url", + "input": "https://mcp.example.test/mcp", + "expect": { "format": "url", "servers": [ { "name": "example", "type": "remote", "url": "https://mcp.example.test/mcp", "headers": {} } ], "placeholders": [] } + }, + { + "id": "sse-only", + "input": "{\"mcpServers\":{\"legacy\":{\"type\":\"sse\",\"url\":\"https://legacy.example.test/sse\"}}}", + "expect": { "format": "claude-json", "servers": [], "errors": ["legacy: SSE-only servers aren’t portable. Use the server’s Streamable HTTP address."] } + }, + { + "id": "url-credentials", + "input": "https://user:secret@docs.example.test/mcp", + "expect": { "format": "url", "servers": [], "errors": ["Put credentials in a header, not in the URL."] } + }, + { + "id": "broken-json", + "input": "{ \"mcpServers\": { \"issues\": ", + "expect": { "format": "json", "servers": [], "errors": ["Couldn’t read that as JSON. Check that it’s complete."] } + } + ] +} diff --git a/tests/mcp-api.test.mjs b/tests/mcp-api.test.mjs new file mode 100644 index 0000000..e79e5d0 --- /dev/null +++ b/tests/mcp-api.test.mjs @@ -0,0 +1,116 @@ +// Check the authenticated MCP HTTP boundary in a disposable agent home. +import assert from "node:assert/strict"; +import test from "node:test"; +import path from "node:path"; +import os from "node:os"; +import * as fs from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; + +test("shared MCP API creates, links and removes servers without leaking secrets", { timeout: 20000 }, async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "t3-mcp-api-")); + const home = path.join(root, "home"); + await fs.mkdir(home, { recursive: true }); + const reservation = createServer().listen(0, "127.0.0.1"); + await once(reservation, "listening"); + const port = reservation.address().port; + await new Promise((resolve) => reservation.close(resolve)); + const child = spawn(process.execPath, [fileURLToPath(new URL("../docker/setup/server.mjs", import.meta.url))], { env: { + ...process.env, HOME: home, T3CODE_HOME: path.join(home, ".t3"), T3_WORKSPACE: path.join(root, "workspace"), + CODEX_HOME: path.join(home, ".codex"), CLAUDE_CONFIG_DIR: path.join(home, ".claude"), XDG_CONFIG_HOME: path.join(home, ".config"), + MISE_DATA_DIR: path.join(home, ".local/share/mise"), MISE_STATE_DIR: path.join(home, ".local/state/mise"), + MISE_CONFIG_DIR: path.join(home, ".config/mise"), T3_SETUP_PORT: String(port), T3_SETUP_KEY: "mcp-fixture-key", + T3_SETUP_KEY_SOURCE: "env", T3_INFRA_LAUNCHER: "/bin/false", T3CODE_PORT: "1", + }, stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"); + let logs = ""; + child.stdout.on("data", (chunk) => { logs += chunk; }); + child.stderr.on("data", (chunk) => { logs += chunk; }); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) child.kill(); + await closed; + // Match the other API fixtures: native probes may be draining work after + // the setup process exits, so cleanup retries are bounded. + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + const base = `http://127.0.0.1:${port}/__setup`; + let ready = false; + for (let i = 0; i < 200; i++) { + if (child.exitCode !== null) throw new Error(logs); + try { if ((await fetch(base + "/hello")).ok) { ready = true; break; } } catch {} + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.ok(ready, logs); + const get = (route) => fetch(base + route, { headers: { "x-t3-setup-key": "mcp-fixture-key" } }); + const post = (route, body, headers = {}) => fetch(base + route, { method: "POST", headers: { "x-t3-setup-key": "mcp-fixture-key", "content-type": "application/json", ...headers }, body: JSON.stringify(body) }); + assert.equal((await fetch(base + "/context/mcp")).status, 401); + const embeddedPage = await (await get('/?embed=t3')).text(); + assert.match(embeddedPage, //); + assert.match(await (await get('/?embed=1')).text(), //); + const login = await fetch(base + '/login', { method: 'POST', redirect: 'manual', body: new URLSearchParams({ key: 'mcp-fixture-key', embed: 't3' }) }); + assert.equal(login.status, 303); + assert.equal(login.headers.get('location'), '/__setup/?embed=t3', 'Unlocking must retain T3 embedding for the MCP close action'); + assert.equal((await post("/context/mcp/create", { server: { name: "x", type: "local", command: ["x"] } }, { "sec-fetch-site": "cross-site" })).status, 403); + assert.equal((await post("/context/mcp/create", { server: { name: "Bad Name", type: "local", command: ["x"] } })).status, 400); + assert.equal((await post("/context/mcp/create", null)).status, 400); + + const created = await (await post("/context/mcp/create", { server: { name: "playwright", type: "local", command: ["playwright-mcp", "--headless"], env: { TOKEN: "secret" } } })).json(); + assert.equal(created.ok, true); + const status = await (await get("/status")).json(); + assert.equal(status.context.mcp.shared.count, 1); + assert.equal(JSON.stringify(status).includes('"secret"'), false); + assert.deepEqual(status.context.mcp.providers.find((row) => row.provider === "codex").linked, []); + assert.match(status.context.mcp.providers.find((row) => row.provider === "grok").error, /not implemented/); + + const read = await (await get("/context/mcp/read?name=playwright")).json(); + assert.equal(read.server.env.TOKEN, "secret"); + assert.equal((await get("/context/mcp/read")).status, 400); + assert.equal((await get("/context/mcp/read?name=missing")).status, 404); + assert.equal((await get("/context/mcp?provider=nope")).status, 400); + assert.equal((await get("/context/mcp?provider=grok")).status, 403); + + const providerRevision = (provider) => get('/context/mcp?provider=' + provider).then(r => r.json()).then(r => r.revision); + const link = await post("/context/mcp/link", { provider: "codex", name: "playwright", linked: true, sharedRevision: created.revision, providerRevision: await providerRevision('codex') }); + assert.equal(link.status, 200); + assert.match(await fs.readFile(path.join(home, ".codex", "config.toml"), "utf8"), /playwright-mcp/); + const codex = await (await get("/context/mcp?provider=codex")).json(); + assert.equal(codex.shared[0].linked, true); + assert.equal((await post("/context/mcp/link", { provider: "codex", name: "playwright", linked: true, sharedRevision: "stale", providerRevision: await providerRevision('codex') })).status, 409); + assert.equal((await post("/context/mcp/link", { provider: "codex", name: "playwright", linked: false, sharedRevision: created.revision })).status, 400); + const unlink = await post("/context/mcp/link", { provider: "claude", name: "playwright", linked: false, sharedRevision: created.revision, providerRevision: await providerRevision('claude') }); + assert.equal(unlink.status, 200); + assert.equal((await post("/context/mcp/link", { provider: "grok", name: "playwright", linked: true, sharedRevision: created.revision, providerRevision: 'unused' })).status, 403); + + const saved = await post("/context/mcp/save", { name: "playwright", revision: "stale", server: { name: "playwright", type: "local", command: ["other"], env: {} } }); + assert.equal(saved.status, 409); + assert.equal((await post("/context/mcp/remove", { name: "playwright", revision: created.revision })).status, 409); + assert.equal((await post("/context/mcp/link", { provider: "codex", name: "playwright", linked: false, sharedRevision: created.revision, providerRevision: await providerRevision('codex') })).status, 200); + const removed = await post("/context/mcp/remove", { name: "playwright", revision: created.revision }); + assert.equal(removed.status, 200); + assert.equal((await get("/context/mcp/read?name=playwright")).status, 404); + const malformed = await fetch(base + '/context/mcp/create', { method: 'POST', headers: { 'x-t3-setup-key': 'mcp-fixture-key', 'content-type': 'application/json' }, body: '{\"server\":{\"env\":{\"TOKEN\":\"fixture-secret-malformed\"' }); + assert.equal(malformed.status, 400); assert.equal((await malformed.text()).includes('fixture-secret-malformed'), false); + const parsed = await (await post('/context/mcp/parse', { text: 'uvx mcp-server-fetch' })).json(); + assert.equal(parsed.servers[0].name, 'fetch'); + assert.equal((await post('/context/mcp/parse', { text: 'x'.repeat(65537) })).status, 413); + assert.equal((await post('/context/mcp/parse', { text: 123 })).status, 400); + await fs.mkdir(path.join(home, '.claude'), { recursive: true }); + await fs.writeFile(path.join(home, '.claude/.claude.json'), JSON.stringify({ mcpServers: { docs: { type: 'http', url: 'https://docs.test/mcp', headers: { Authorization: 'fixture-private-header' }, timeout: 10 } } })); + const own = await (await get('/context/mcp?refresh=1')).json(); + assert.equal(own.ownGroups[0].name, 'docs'); + assert.equal(JSON.stringify(own).includes('fixture-private-header'), false); + const claude = own.providers.find(p => p.provider === 'claude'); + assert.equal((await fetch(base + '/context/mcp/read?provider=claude&name=docs')).status, 401); + assert.equal((await fetch(base + '/context/mcp/diff?provider=claude&name=docs')).status, 401); + assert.equal((await post('/context/mcp/adopt', { provider: 'claude', name: 'docs', providerRevision: 'stale', sharedRevision: own.shared.revision })).status, 409); + const adopted = await (await post('/context/mcp/adopt', { provider: 'claude', name: 'docs', providerRevision: claude.revision, sharedRevision: own.shared.revision })).json(); + assert.deepEqual(adopted.dropped, ['timeout']); + const diff = await (await get('/context/mcp/diff?provider=claude&name=docs')).json(); + assert.equal(diff.adoptable, true); assert.equal(JSON.stringify(diff).includes('fixture-private-header'), false); + const agentRead = await (await get('/context/mcp/read?provider=claude&name=docs')).json(); + assert.equal(agentRead.server.headers.Authorization, 'fixture-private-header'); + assert.equal((await post('/context/mcp/link', { provider: 'claude', name: 'docs', linked: true, enabled: false, providerRevision: claude.revision, sharedRevision: adopted.revision })).status, 403); + +}); diff --git a/tests/mcp-model.test.mjs b/tests/mcp-model.test.mjs new file mode 100644 index 0000000..bae9a4d --- /dev/null +++ b/tests/mcp-model.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +const context = vm.createContext({}); vm.runInContext(readFileSync(new URL('../docker/setup/client/mcp-model.js', import.meta.url), 'utf8') + '\nthis.S = T3McpModel;', context); +const S = context.S; +const fixture = () => JSON.parse(readFileSync(new URL('../scripts/fixtures/console/mcp.json', import.meta.url))); +const plain = value => JSON.parse(JSON.stringify(value)); +test('MCP columns use installation state without treating unsupported agents as uninstalled', () => { + assert.deepEqual(plain(S.columns(['claude', 'muse']).map(p => p.id)), ['claude', 'muse']); + assert.equal(S.columns(['claude'], true).length, 7); + assert.equal(S.columns([]).length, 0); + assert.equal(S.columns([], true).length, 7); + assert.equal(S.columns(S.providers.map(p => p.id)).length, 7); +}); +test('definition comparison ignores only the agent state, never named credential values', () => { + const a = { name: 'server', type: 'local', command: ['server'], enabled: true, env: { enabled: 'one', TOKEN: 'secret' } }; + assert.equal(S.sameDefinition(a, { ...a, enabled: false }), true); + assert.equal(S.sameDefinition(a, { ...a, env: { TOKEN: 'secret', enabled: 'one' } }), true); + assert.equal(S.sameDefinition(a, { ...a, env: { enabled: 'two', TOKEN: 'secret' } }), false); + assert.equal(S.sameValue(a.env, { ...a.env, enabled: 'two' }), false); + assert.equal(S.sameValue({ enabled: 'one' }, {}), false); + assert.equal(S.sameDefinition({ headers: { enabled: 'one' } }, { headers: { enabled: 'two' } }), false); +}); +test('MCP rows join shared and own entries without claiming live connections', () => { + const data = fixture(), rows = S.rows(data); + assert.equal(rows.length, 5); assert.equal(rows.filter(r => r.shared).length, 3); + const docs = rows.find(r => r.name === 'docs'), db = rows.find(r => r.name === 'database'), own = rows.find(r => r.name === 'github'); + assert.equal(S.state(data, docs, 'claude').kind, 'differs'); + assert.equal(S.state(data, docs, 'codex').kind, 'on'); + assert.equal(S.state(data, db, 'codex').kind, 'off'); + assert.equal(S.state(data, docs, 'cursor').kind, 'none'); + assert.equal(S.state(data, own, 'claude').kind, 'own'); + assert.equal(S.state(data, own, 'codex').kind, 'none'); + assert.equal(S.state(data, docs, 'grok').label, 'Via Claude Code'); + assert.equal(S.state(data, db, 'grok').label, '—'); + assert.equal(S.state(data, docs, 'muse').kind, 'unsupported'); + data.providers.find(p => p.provider === 'cursor').error = 'Unreadable'; + assert.equal(S.state(data, docs, 'cursor').kind, 'error'); +}); +test('Grok imports prefer Claude, then Cursor, and do not assert effective discovery', () => { + const data = fixture(), docs = S.rows(data).find(r => r.name === 'docs'); + data.providers.find(p => p.provider === 'claude').diverged = []; + data.providers.find(p => p.provider === 'cursor').linked = ['docs']; + assert.equal(S.state(data, docs, 'grok').label, 'Via Cursor'); + data.providers.find(p => p.provider === 'grok').imports = []; + assert.equal(S.state(data, docs, 'grok').label, '—'); +}); +test('filters include native command and URL summaries and attention counts are per copy', () => { + const data = fixture(); + assert.deepEqual(plain(S.filter(data, { query: 'ghcr.io' }).map(r => r.name)), ['github']); + assert.deepEqual(plain(S.filter(data, { query: 'docs.example' }).map(r => r.name)), ['docs']); + assert.equal(S.filter(data, { agent: 'codex' }).length, 3); + assert.equal(S.filter(data, { attention: true }).length, 1); + data.providers.find(p => p.provider === 'cursor').error = 'Unreadable'; + assert.equal(S.attention(data).length, 2); + assert.equal(S.attention(data, ['codex']).length, 0); + assert.equal(S.providers.length, 7); +}); diff --git a/tests/mcp-parse.test.mjs b/tests/mcp-parse.test.mjs new file mode 100644 index 0000000..171240e --- /dev/null +++ b/tests/mcp-parse.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import Command from '../docker/setup/client/mcp-command.js'; +import { parseMcpSnippet } from '../docker/setup/mcp/parse.mjs'; +const cases = JSON.parse(readFileSync(new URL('./fixtures/mcp-snippets.json', import.meta.url))).cases; +for (const item of cases) test('MCP snippet: ' + item.id, () => { + const actual = parseMcpSnippet(item.input); + for (const [key, value] of Object.entries(item.expect)) assert.deepEqual(actual[key], value, key); +}); +test('command display round-trips losslessly without expansion', () => { + const argv = ['tool', '', ' leading ', 'a\nb', "a'b", 'a"b', '$HOME', '$(touch /tmp/oops)', '*', '\\']; + assert.deepEqual(Command.lex(Command.line(argv)), argv); + assert.equal(Command.needsRows(argv), true); + assert.throws(() => Command.lex('tool "unfinished'), /quote/); +}); +test('parser is bounded, duplicate aware and does not reinterpret broken data as commands', () => { + assert.match(parseMcpSnippet('x'.repeat(65537)).errors[0], /too long/); + assert.match(parseMcpSnippet('{"mcpServers":{"x":{"command":"a","command":"b"}}}').errors[0], /JSON/); + assert.equal(parseMcpSnippet('[broken').servers.length, 0); + assert.equal(parseMcpSnippet('{"mcpServers":{"x":{"command":"a","args":"oops"}}}').servers.length, 0); + const ref = parseMcpSnippet('{"url":"https://docs.example.test/mcp","headers":{"Authorization":"${TOKEN}"}}'); + assert.deepEqual(ref.placeholders, []); assert.match(ref.notes.join(), /resolved differently/); + const sse = parseMcpSnippet('https://docs.example.test/sse'); + assert.match(sse.errors[0], /SSE-only/); +}); diff --git a/tests/mcp-redesign.test.mjs b/tests/mcp-redesign.test.mjs new file mode 100644 index 0000000..adf5391 --- /dev/null +++ b/tests/mcp-redesign.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createMcpService } from '../docker/setup/mcp.mjs'; +import { readProviderMcp } from '../docker/setup/mcp/adapters.mjs'; +async function fixture(t) { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 't3-mcp-design-')); + t.after(() => fs.rm(home, { recursive: true, force: true })); + const options = { home, env: { HOME: home }, io: fs, baseDir: path.join(home, '.t3') }; + return { ...options, service: createMcpService(options) }; +} +const local = { name: 'example', type: 'local', command: ['do-not-execute'], env: { TOKEN: 'fixture-shared-secret' } }; +for (const provider of ['codex', 'opencode']) for (const v2 of provider === 'opencode' ? [false, true] : [false]) test(`${provider} ${v2 ? 'V2' : ''}: per-agent off survives editing and never diverges`, async t => { + const f = await fixture(t), svc = f.service; + if (v2) { await fs.mkdir(path.join(f.home, '.config/opencode'), { recursive: true }); await fs.writeFile(path.join(f.home, '.config/opencode/opencode.jsonc'), '{// keep\n"mcp":{"timeout":3000,"servers":{}}}'); } + let revision = (await svc.create(local)).revision; + let native = await readProviderMcp(fs, provider, f); + await svc.setLink(provider, 'example', true, revision, native.revision, false); + let state = (await svc.status({ refresh: true })).providers.find(p => p.provider === provider); + assert.deepEqual(state.linked, ['example']); assert.deepEqual(state.disabled, ['example']); assert.deepEqual(state.diverged, []); + revision = (await svc.save('example', revision, { ...local, command: ['new-command'] })).revision; + assert.deepEqual((await svc.list(provider)).diverged, ['example']); + await svc.setLink(provider, 'example', true, revision, state.revision); + state = (await svc.list(provider)); assert.deepEqual(state.disabled, ['example']); assert.deepEqual(state.diverged, []); + await svc.setLink(provider, 'example', true, revision, state.revision, true); + assert.deepEqual((await svc.list(provider)).disabled, []); + native = await readProviderMcp(fs, provider, f); + assert.equal(native.servers[0].canonical.enabled, true); + if (v2) { const text = await fs.readFile(native.path, 'utf8'); assert.match(text, /keep/); assert.match(text, /"timeout":\s*3000/); } +}); +for (const provider of ['claude', 'cursor']) test(provider + ' rejects Off without modifying the file', async t => { + const f = await fixture(t), svc = f.service, created = await svc.create(local); + const before = await readProviderMcp(fs, provider, f); + await assert.rejects(svc.setLink(provider, 'example', true, created.revision, before.revision, false), /has no global off switch/); + assert.equal((await readProviderMcp(fs, provider, f)).revision, before.revision); +}); +test('own summaries and grouping compare secrets privately; adopting keeps the native entry exact', async t => { + const f = await fixture(t), svc = f.service; + const file = path.join(f.home, '.claude.json'); + const raw = '{"mcpServers":{"docs":{"type":"http","url":"https://docs.test/path?token=fixture-url-secret","headers":{"Authorization":"fixture-agent-secret"},"timeout":120}}}'; + await fs.writeFile(file, raw, { mode: 0o600 }); + await fs.mkdir(path.join(f.home, '.cursor')); await fs.writeFile(path.join(f.home, '.cursor/mcp.json'), raw); + let state = await svc.status({ refresh: true }); + assert.equal(state.ownGroups[0].identical, true); + const serialized = JSON.stringify(state); for (const secret of ['fixture-agent-secret', 'fixture-url-secret', 'timeout":120']) assert.equal(serialized.includes(secret), false); + const source = state.providers.find(p => p.provider === 'claude'); + assert.deepEqual(source.own[0].extra, ['timeout']); assert.equal(source.own[0].url, 'https://docs.test'); + await assert.rejects(svc.adopt('claude', 'docs', 'stale', state.shared.revision), { code: 'conflict' }); + const adopted = await svc.adopt('claude', 'docs', source.revision, state.shared.revision); + assert.deepEqual(adopted.dropped, ['timeout']); assert.equal(await fs.readFile(file, 'utf8'), raw); + assert.equal((await fs.stat(path.join(f.baseDir, 'setup/mcp/servers.json'))).mode & 0o777, 0o600); + assert.equal((await svc.read('docs')).server.headers.Authorization, 'fixture-agent-secret'); + await assert.rejects(svc.adopt('claude', 'docs', source.revision, adopted.revision), { code: 'conflict' }); + const diff = await svc.diff('claude', 'docs'); assert.deepEqual(diff.extraOptions, ['timeout']); + await fs.writeFile(file, raw.replace('fixture-agent-secret', 'different-secret').replace('/path?', '/v2?')); + const changed = await svc.diff('claude', 'docs'); + assert.ok(changed.fields.some(field => field.field === 'header' && field.secret)); + assert.equal(JSON.stringify(changed).includes('different-secret'), false); + assert.equal(JSON.stringify(changed).includes('fixture-agent-secret'), false); + await assert.rejects(svc.adopt('claude', 'docs', changed.providerRevision, 'stale', true), { code: 'conflict' }); + await svc.adopt('claude', 'docs', changed.providerRevision, changed.sharedRevision, true); + assert.equal((await svc.read('docs')).server.headers.Authorization, 'different-secret'); +}); +test('unsupported own entries stay visible and cannot be adopted', async t => { + const f = await fixture(t), svc = f.service; + await fs.writeFile(path.join(f.home, '.claude.json'), '{"mcpServers":{"legacy":{"type":"sse","url":"https://test/sse"}}}'); + const state = await svc.status(); assert.equal(state.ownGroups[0].identical, false); + const source = state.providers.find(p => p.provider === 'claude'); assert.equal(source.own[0].recognized, false); + await assert.rejects(svc.adopt('claude', 'legacy', source.revision, state.shared.revision), { code: 'unsupported' }); + assert.equal((await svc.status({ refresh: true })).shared.count, 0); +}); diff --git a/tests/mcp-regressions.test.mjs b/tests/mcp-regressions.test.mjs new file mode 100644 index 0000000..e7ac6ce --- /dev/null +++ b/tests/mcp-regressions.test.mjs @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { createMcpService } from "../docker/setup/mcp.mjs"; +import { applyMcpToml, parseMcpToml } from "../docker/setup/mcp/toml.mjs"; +import { readProviderMcp, writeProviderMcpEntry } from "../docker/setup/mcp/adapters.mjs"; +import { mcpStorePathFor, validateMcpServer } from "../docker/setup/mcp/store.mjs"; +const server = (name, extra = {}) => ({ name, type: "local", command: ["server"], env: {}, ...extra }); +async function fixture(t, extraEnv = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "t3-mcp-regression-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const home = path.join(root, "home"), baseDir = path.join(root, "state"); + const env = { HOME: home, ...extraEnv }; + return { root, home, baseDir, env, svc: createMcpService({ home, baseDir, env, io: fs }) }; +} + +test("Codex edits preserve nested servers, multiline strings and unrelated array tables", () => { + const unrelated = '# skill policy\n[[skills.config]]\npath = "/tmp/skill"\nenabled = false\n'; + const raw = '# model\nmodel = "test"\n\n[mcp_servers.probe]\ncommand = "node"\nargs = [\n "a,b",\n "\\\\u0041",\n]\n[mcp_servers.probe.env]\nTOKEN = """first\n[mcp_servers.fake]\nlast"""\n\n' + unrelated; + const parsed = parseMcpToml(raw); + assert.equal(parsed.size, 1); + assert.deepEqual(parsed.get("probe").args, ["a,b", "\\u0041"]); + const removed = applyMcpToml(raw, "probe", null); + assert.ok(removed.includes(unrelated)); + assert.equal(parseMcpToml(removed).size, 0); + assert.ok(removed.startsWith('# model\nmodel = "test"')); + const updated = applyMcpToml(raw, "probe", { command: "other", env: { TOKEN: "a,b\nsecond", EMPTY: "" } }); + assert.deepEqual(parseMcpToml(updated).get("probe"), { command: "other", env: { TOKEN: "a,b\nsecond", EMPTY: "" } }); + assert.ok(updated.includes(unrelated)); +}); + +test("Codex rejects malformed documents before writing and hides parser values", async (t) => { + const f = await fixture(t), file = path.join(f.home, ".codex/config.toml"); + await fs.mkdir(path.dirname(file), { recursive: true }); + const raw = '[mcp_servers.probe]\ncommand="secret-credential\n'; + await fs.writeFile(file, raw); + await assert.rejects(writeProviderMcpEntry(fs, "codex", f, "probe", null), (e) => e.code === "unsupported" && !e.message.includes("secret-credential")); + assert.equal(await fs.readFile(file, "utf8"), raw); +}); + +test("native OpenCode 2 dictionaries are inventoried, updated and unlinked in place", async (t) => { + const f = await fixture(t), file = path.join(f.home, ".config/opencode/opencode.jsonc"); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, '{\n// retained\n"mcp":{"timeout":10000,"servers":{"probe":{"type":"local","command":["server"],"disabled":true},"own":{"type":"remote","url":"https://example.test/mcp"}}}\n}\n'); + const created = await f.svc.create(server("probe", { enabled: false })); + assert.equal((await f.svc.list("opencode")).shared[0].linked, true); + const saved = await f.svc.save("probe", created.revision, server("probe")); + await f.svc.setLink("opencode", "probe", true, saved.revision, (await f.svc.list("opencode")).revision, true); + assert.equal((await f.svc.list("opencode")).shared[0].linked, true); + assert.match(await fs.readFile(file, "utf8"), /"disabled": false/); + await f.svc.setLink("opencode", "probe", false, saved.revision, (await f.svc.list("opencode")).revision); + const read = await readProviderMcp(fs, "opencode", f); + assert.deepEqual(read.servers.map((r) => r.name), ["own"]); + const raw = await fs.readFile(file, "utf8"); + assert.match(raw, /retained/); assert.match(raw, /"timeout":\s*10000/); +}); + +test("concurrent links retain both entries, also through a provider-file symlink", async (t) => { + for (const provider of ["claude", "codex", "opencode", "cursor"]) { + const f = await fixture(t); + const file = provider === "claude" ? path.join(f.home, ".claude.json") : provider === "codex" ? path.join(f.home, ".codex/config.toml") : provider === "cursor" ? path.join(f.home, ".cursor/mcp.json") : path.join(f.home, ".config/opencode/opencode.json"); + await fs.mkdir(path.dirname(file), { recursive: true }); + const target = path.join(f.root, "persistent-config"); + await fs.writeFile(target, provider === "codex" ? '# retained\nmodel="test"\n' : '{"retained":true}\n', { mode: 0o600 }); + await fs.symlink(target, file); + await f.svc.create(server("first")); const second = await f.svc.create(server("second")); + await Promise.all([f.svc.setLink(provider, "first", true, second.revision), f.svc.setLink(provider, "second", true, second.revision)]); + assert.equal((await fs.lstat(file)).isSymbolicLink(), true, provider); + const read = await readProviderMcp(fs, provider, f); + assert.deepEqual(read.servers.map((r) => r.name).sort(), ["first", "second"], provider); + assert.match(await fs.readFile(target, "utf8"), /retained/); + const backups = (await fs.readdir(f.root)).filter(n => n.includes('.t3-mcp-backup.')); + assert.equal(backups.length, 2); + for (const backup of backups) assert.equal((await fs.stat(path.join(f.root, backup))).mode & 0o777, 0o600); + } +}); + +test("provider revisions refuse external edits and retargeted symlinks", async (t) => { + const f = await fixture(t), created = await f.svc.create(server("probe")); + const read = await f.svc.list("claude"); + await fs.mkdir(f.home, { recursive: true }); + const file = path.join(f.home, ".claude.json"); + await fs.writeFile(file, '{"retained":true}'); + await assert.rejects(f.svc.setLink("claude", "probe", true, created.revision, read.revision), { code: "conflict" }); + const a = path.join(f.root, 'a'), b = path.join(f.root, 'b'); + await fs.writeFile(a, '{}'); await fs.writeFile(b, '{}'); await fs.rm(file); await fs.symlink(a, file); + const revision = (await f.svc.list("claude")).revision; + await fs.rm(file); await fs.symlink(b, file); + await assert.rejects(f.svc.setLink("claude", "probe", true, created.revision, revision), { code: "conflict" }); + assert.equal(await fs.readFile(b, 'utf8'), '{}'); +}); + +test("legacy shared disable does not change Claude’s native state and removal requires unlink", async (t) => { + const f = await fixture(t), created = await f.svc.create(server("probe")); + await f.svc.setLink("claude", "probe", true, created.revision); + const saved = await f.svc.save("probe", created.revision, server("probe", { enabled: false })); + const state = (await f.svc.status({ refresh: true })).providers.find(p => p.provider === 'claude'); + assert.deepEqual(state.linked, ['probe']); assert.deepEqual(state.diverged, []); assert.deepEqual(state.unlinked, []); + await assert.rejects(f.svc.remove("probe", saved.revision), { code: 'conflict' }); + await f.svc.setLink("claude", "probe", false, saved.revision, state.revision); + assert.equal((await f.svc.remove("probe", saved.revision)).ok, true); +}); + +test("store limits reject writes and leave a usable previous store", async (t) => { + const f = await fixture(t), original = await f.svc.create(server("first")); + const large = Object.fromEntries(Array.from({length:33},(_,i)=>['KEY_'+i,'x'.repeat(8192)])); + await assert.rejects(f.svc.create(server('large', { env: large })), { code: 'too_large' }); + await assert.rejects(f.svc.save('first', original.revision, server('first', { env: large })), { code: 'too_large' }); + assert.equal((await f.svc.read('first')).revision, original.revision); + const entries = Array.from({length:128},(_,i)=>server('s'+i)); + await fs.writeFile(mcpStorePathFor(f.baseDir), JSON.stringify({servers:entries})); + await assert.rejects(f.svc.create(server('extra')), { code: 'too_large' }); + assert.equal((await f.svc.status({refresh:true})).shared.count, 128); +}); + +test("URLs, headers, unsupported providers and extra provider options are honest", async (t) => { + const f = await fixture(t); + for (const url of ['https://user:password@example.test', 'https://', 'https://host\n.test', 'https://[broken']) assert.equal(validateMcpServer({name:'remote',type:'remote',url}).ok, false); + assert.equal(validateMcpServer({name:'remote',type:'remote',url:'https://example.test',headers:{Authorization:'a',authorization:'b'}}).ok, false); + await f.svc.create({name:'remote',type:'remote',url:'https://example.test/private-secret-path?token=secret',headers:{Authorization:'secret'}}); + const c = await f.svc.create(server('probe')); + await f.svc.setLink('codex','probe',true,c.revision); + const file = path.join(f.home,'.codex/config.toml'); + await fs.writeFile(file, '[mcp_servers.probe]\ncommand="server"\ncwd="/different"\n'); + const status = await f.svc.status({refresh:true}); + assert.equal(status.shared.servers.find(s=>s.name==='remote').url, 'https://example.test'); + assert.equal(JSON.stringify(status).includes('private-secret-path'), false); + assert.equal(JSON.stringify(status).includes('token=secret'), false); + assert.deepEqual(status.providers.find(p=>p.provider==='codex').diverged,['probe']); + await assert.rejects(f.svc.setLink('antigravity','probe',true,c.revision), {code:'unsupported'}); +}); + +test("Claude override roots are honored and duplicate JSON keys cannot be changed", async (t) => { + const f = await fixture(t, {CLAUDE_CONFIG_DIR:'custom-claude'}), c = await f.svc.create(server('probe')); + await f.svc.setLink('claude','probe',true,c.revision); + const file=path.join(f.home,'custom-claude/.claude.json'); + assert.equal(JSON.parse(await fs.readFile(file,'utf8')).mcpServers.probe.command,'server'); + const raw='{"mcpServers":{},"mcpServers":{"probe":{"command":"existing"}}}'; + await fs.writeFile(file,raw); + await assert.rejects(f.svc.setLink('claude','probe',false,c.revision),{code:'unsupported'}); + assert.equal(await fs.readFile(file,'utf8'),raw); +}); + +test("mixed transport fields differ and malformed native maps are not recognized", async (t) => { + for (const provider of ['claude', 'codex', 'opencode', 'cursor']) { + const f = await fixture(t); + const created = await f.svc.create({name:'probe',type:'remote',url:'https://example.test/mcp',headers:{}}); + const mixed = provider === 'codex' ? {url:'https://example.test/mcp',command:'extra'} + : provider === 'opencode' ? {type:'remote',url:'https://example.test/mcp',command:['extra']} + : {type:'http',url:'https://example.test/mcp',command:'extra'}; + await writeProviderMcpEntry(fs,provider,f,'probe',mixed); + assert.equal((await f.svc.list(provider)).shared[0].diverged,true,provider); + await f.svc.setLink(provider,'probe',true,created.revision,(await f.svc.list(provider)).revision); + assert.equal((await f.svc.list(provider)).shared[0].linked,true,provider); + const headerKey = provider === 'codex' ? 'http_headers' : 'headers'; + await writeProviderMcpEntry(fs,provider,f,'probe',{...mixed,[headerKey]:'not-an-object'}); + assert.equal((await readProviderMcp(fs,provider,f)).servers[0].canonical,null,provider); + const local = provider === 'opencode' ? {type:'local',command:['server'],environment:0} + : {command:'server',env:0}; + await writeProviderMcpEntry(fs,provider,f,'probe',local); + assert.equal((await readProviderMcp(fs,provider,f)).servers[0].canonical,null,provider); + if (['claude','cursor'].includes(provider)) { + await writeProviderMcpEntry(fs,provider,f,'probe',{command:'server',type:'http'}); + assert.equal((await readProviderMcp(fs,provider,f)).servers[0].canonical,null,provider); + } + } +}); + +test("the browser CLI and console can alternate without duplicate TOML or ignored JSONC/V2 entries", async (t) => { + const f = await fixture(t), bin = path.join(f.root, 'bin'); + await fs.mkdir(bin); + for (const name of ['playwright-mcp', 'managed']) await fs.writeFile(path.join(bin, name), '#!/bin/sh\nexit 0\n', {mode:0o755}); + const resolver = path.join(f.root, 'resolve.mjs'); + await fs.writeFile(resolver, `console.log(${JSON.stringify(path.join(bin, 'managed'))});`); + const adapters = new URL('../docker/setup/mcp/adapters.mjs', import.meta.url).href; + const command = fileURLToPath(new URL('../docker/bin/t3-browser-mcp', import.meta.url)); + const env = {...process.env,...f.env,PATH:bin+path.delimiter+process.env.PATH,T3_INFRA_NODE:process.execPath,T3_PROVIDER_CLI:resolver,T3_MCP_ADAPTERS_MODULE:adapters,CODEX_HOME:path.join(f.home,'.codex'),XDG_CONFIG_HOME:path.join(f.home,'.config')}; + delete env.CLAUDE_CONFIG_DIR; delete env.OPENCODE_CONFIG_DIR; + const ocFile=path.join(f.home,'.config/opencode/opencode.jsonc'); + await fs.mkdir(path.dirname(ocFile),{recursive:true});await fs.writeFile(ocFile,'{\n// keep\n"mcp":{"servers":{}}\n}\n'); + const svc=createMcpService({home:f.home,baseDir:f.baseDir,env,io:fs}); + const created=await svc.create(server('playwright')); + for(const provider of ['codex','opencode']) { + await svc.setLink(provider,'playwright',true,created.revision); + for(let i=0;i<2;i++) await promisify(execFile)('bash',[command,'--harness',provider],{env,timeout:15000}); + assert.equal((await readProviderMcp(fs,provider,{home:f.home,env})).servers.filter(r=>r.name==='playwright').length,1); + await svc.setLink(provider,'playwright',true,created.revision); + await promisify(execFile)('bash',[command,'--harness',provider,'--remove'],{env,timeout:15000}); + assert.equal((await svc.list(provider)).shared[0].linked,false); + } + assert.match(await fs.readFile(ocFile,'utf8'),/keep/); +}); + + +test("Claude HTTP needs an explicit transport while Cursor accepts its URL default", async (t) => { + const f = await fixture(t); + const url = 'https://example.test/mcp'; + await f.svc.create({name:'probe',type:'remote',url,headers:{}}); + await writeProviderMcpEntry(fs,'claude',f,'probe',{url}); + assert.equal((await f.svc.list('claude')).shared[0].diverged,true); + assert.equal((await readProviderMcp(fs,'claude',f)).servers[0].canonical,null); + await writeProviderMcpEntry(fs,'cursor',f,'probe',{url}); + assert.equal((await f.svc.list('cursor')).shared[0].linked,true); + await writeProviderMcpEntry(fs,'claude',f,'probe',{type:'streamable-http',url}); + assert.equal((await f.svc.list('claude')).shared[0].linked,true); +}); + +test("OpenCode V2 global options without servers retain their shape when linking", async (t) => { + for (const options of [{codemode:false},{protocol:'legacy'},{timeout:{startup:30000}}]) { + const f = await fixture(t), file = path.join(f.home,'.config/opencode/opencode.jsonc'); + await fs.mkdir(path.dirname(file),{recursive:true}); + await fs.writeFile(file,JSON.stringify({mcp:options})); + const created = await f.svc.create(server('probe')); + assert.deepEqual((await readProviderMcp(fs,'opencode',f)).servers,[]); + await f.svc.setLink('opencode','probe',true,created.revision); + assert.deepEqual(JSON.parse(await fs.readFile(file,'utf8')).mcp,{...options,servers:{probe:{type:'local',command:['server'],disabled:false}}}); + assert.equal((await f.svc.list('opencode')).shared[0].linked,true); + } + // V1 servers can use these names; transport-bearing objects are entries. + const f = await fixture(t); + for (const name of ['servers','timeout','codemode','protocol']) await writeProviderMcpEntry(fs,'opencode',f,name,{type:'local',command:['server']}); + assert.deepEqual((await readProviderMcp(fs,'opencode',f)).servers.map(s=>s.name),['servers','timeout','codemode','protocol']); +}); + +test("OpenCode inventory distinguishes V1 enabled and V2 disabled flags", async (t) => { + for (const v2 of [false, true]) for (const enabled of [false, true]) { + const f = await fixture(t), file = path.join(f.home,'.config/opencode/opencode.json'); + await fs.mkdir(path.dirname(file),{recursive:true}); + const wrongFlag = v2 ? {enabled} : {disabled:!enabled}; + const entry = {type:'local',command:['server'],...wrongFlag}; + await fs.writeFile(file,JSON.stringify({mcp:v2 ? {servers:{probe:entry}} : {probe:entry}})); + const created = await f.svc.create(server('probe',{enabled})); + assert.equal((await f.svc.list('opencode')).shared[0].linked,false); + assert.equal((await f.svc.list('opencode')).shared[0].diverged,true); + const native = await readProviderMcp(fs,'opencode',f); + assert.equal(native.servers[0].extra,true); + assert.equal(native.servers[0].canonical.enabled,true,'the other format’s flag must not change native state'); + await f.svc.setLink('opencode','probe',true,created.revision,native.revision,enabled); + const repaired = await readProviderMcp(fs,'opencode',f); + assert.equal((await f.svc.list('opencode')).shared[0].linked,true); + assert.equal(repaired.servers[0].canonical.enabled,enabled); + assert.deepEqual(repaired.servers[0].entry,{type:'local',command:['server'],...(v2 ? {disabled:!enabled} : {enabled})}); + } +}); diff --git a/tests/mcp.test.mjs b/tests/mcp.test.mjs new file mode 100644 index 0000000..a47b12a --- /dev/null +++ b/tests/mcp.test.mjs @@ -0,0 +1,176 @@ +// Shared MCP servers: store validation, TOML preservation, provider +// translation and link-state derivation. +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import { validateMcpServer, readMcpStore, createMcpServer, saveMcpServer, removeMcpServer, redactMcpServer, mcpRevision } from "../docker/setup/mcp/store.mjs"; +import { parseMcpToml, applyMcpToml } from "../docker/setup/mcp/toml.mjs"; +import { toClaudeEntry, fromClaudeEntry, toCodexEntry, fromCodexEntry, toOpenCodeEntry, fromOpenCodeEntry, toCursorEntry, fromCursorEntry, readProviderMcp, writeProviderMcpEntry } from "../docker/setup/mcp/adapters.mjs"; +import { createMcpService } from "../docker/setup/mcp.mjs"; + +async function fixture(t) { + const root = await fs.mkdtemp(path.join(tmpdir(), "t3-mcp-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return { root, home: path.join(root, "home"), baseDir: path.join(root, ".t3") }; +} +const svcFor = (f, extraEnv = {}) => createMcpService({ home: f.home, env: { HOME: f.home, ...extraEnv }, io: fs, baseDir: f.baseDir }); + +test("validation rejects bad names, commands, urls and secrets", () => { + assert.equal(validateMcpServer(null).ok, false); + assert.equal(validateMcpServer({ name: "Bad Name", type: "local", command: ["x"] }).ok, false); + assert.equal(validateMcpServer({ name: "ok", type: "local", command: [] }).ok, false); + assert.equal(validateMcpServer({ name: "ok", type: "local", command: ["x"], env: { "bad-key": "v" } }).ok, false); + assert.equal(validateMcpServer({ name: "ok", type: "remote", url: "ftp://x" }).ok, false); + assert.equal(validateMcpServer({ name: "ok", type: "remote", url: "https://x", headers: { bad: "a\nb" } }).ok, false); + const local = validateMcpServer({ name: "playwright", type: "local", command: ["playwright-mcp", "--headless"], env: { A: "1" } }); + assert.equal(local.ok, true); + assert.deepEqual(local.server, { name: "playwright", type: "local", enabled: true, command: ["playwright-mcp", "--headless"], env: { A: "1" } }); + const remote = validateMcpServer({ name: "notion", type: "remote", url: "https://mcp.notion.com/mcp", enabled: false }); + assert.equal(remote.ok, true); + assert.deepEqual(remote.server.headers, {}); +}); + +test("the shared store round-trips with revisions and conflicts", async (t) => { + const f = await fixture(t); + const svc = svcFor(f); + assert.equal((await svc.status()).shared.present, false); + const created = await svc.create({ name: "playwright", type: "local", command: ["playwright-mcp"], env: {} }); + assert.equal(created.ok, true); + await assert.rejects(svc.create({ name: "playwright", type: "local", command: ["other"], env: {} }), { code: "conflict" }); + const read = await svc.read("playwright"); + assert.deepEqual(read.server.command, ["playwright-mcp"]); + const saved = await svc.save("playwright", read.revision, { name: "playwright", type: "local", command: ["playwright-mcp", "--headless"], env: {} }); + assert.equal(saved.ok, true); + await assert.rejects(svc.save("playwright", read.revision, { name: "playwright", type: "local", command: ["stale"], env: {} }), { code: "conflict" }); + await assert.rejects(svc.remove("playwright", "stale"), { code: "conflict" }); + assert.equal((await svc.remove("playwright", saved.revision)).ok, true); + assert.equal((await svc.status({ refresh: true })).shared.present, false); + await assert.rejects(svc.read("playwright"), { code: "missing" }); +}); + +test("status never carries secrets", async (t) => { + const f = await fixture(t); + const svc = svcFor(f); + await svc.create({ name: "db", type: "remote", url: "https://db.example/mcp", headers: { Authorization: "Bearer secret" } }); + const status = await svc.status({ refresh: true }); + assert.equal(JSON.stringify(status).includes("Bearer secret"), false); + assert.equal(status.shared.servers[0].secretCount, 1); + assert.deepEqual(status.shared.servers[0].headerKeys, ["Authorization"]); + const redacted = redactMcpServer({ name: "db", type: "remote", enabled: true, url: "https://db.example/mcp", headers: { Authorization: "Bearer secret" } }); + assert.equal(redacted.headers, undefined); + assert.equal(redacted.headerCount, 1); +}); + +test("codex TOML edits preserve unrelated sections", () => { + const original = '# comment\nmodel = "o4-mini"\n\n[mcp_servers.playwright]\ncommand = "playwright-mcp"\nargs = ["--headless"]\n\n[other]\nkey = 1\n'; + const parsed = parseMcpToml(original); + assert.deepEqual(parsed.get("playwright"), { command: "playwright-mcp", args: ["--headless"] }); + const updated = applyMcpToml(original, "notion", { url: "https://mcp.notion.com/mcp", enabled: false }); + assert.match(updated, /model = "o4-mini"/); + assert.match(updated, /\[mcp_servers\.playwright\]/); + assert.deepEqual(parseMcpToml(updated).get("notion"), { url: "https://mcp.notion.com/mcp", enabled: false }); + assert.match(updated, /\[other\]/); + const removed = applyMcpToml(updated, "notion", null); + assert.doesNotMatch(removed, /notion/); + assert.match(removed, /playwright-mcp/); + assert.throws(() => parseMcpToml("[mcp_servers.a]\ncommand = 1\n[mcp_servers.a]\ncommand = 2\n"), /safely edited/); +}); + +test("every supported provider translates a local and a remote server", () => { + const local = { name: "playwright", type: "local", enabled: true, command: ["playwright-mcp", "--headless"], env: { FOO: "bar" } }; + const remote = { name: "notion", type: "remote", enabled: true, url: "https://mcp.notion.com/mcp", headers: { Authorization: "Bearer x" } }; + assert.deepEqual(toClaudeEntry(local), { command: "playwright-mcp", args: ["--headless"], env: { FOO: "bar" } }); + assert.deepEqual(fromClaudeEntry("playwright", toClaudeEntry(local)), local); + assert.deepEqual(fromClaudeEntry("notion", toClaudeEntry(remote)), remote); + assert.deepEqual(toCodexEntry(remote), { url: "https://mcp.notion.com/mcp", http_headers: { Authorization: "Bearer x" } }); + assert.deepEqual(fromCodexEntry("notion", toCodexEntry(remote)), remote); + assert.deepEqual(toOpenCodeEntry(local), { type: "local", command: ["playwright-mcp", "--headless"], environment: { FOO: "bar" }, enabled: true }); + assert.deepEqual(fromOpenCodeEntry("playwright", toOpenCodeEntry(local)), local); + assert.deepEqual(fromOpenCodeEntry("notion", toOpenCodeEntry(remote)), remote); + assert.deepEqual(toCursorEntry(local), toClaudeEntry(local)); + assert.deepEqual(fromCursorEntry("playwright", toCursorEntry(local)), local); + assert.equal(fromClaudeEntry("x", { nonsense: true }), null); +}); + +test("links write provider files and derive state by comparison", async (t) => { + const f = await fixture(t); + const svc = svcFor(f, { CODEX_HOME: path.join(f.home, ".codex"), XDG_CONFIG_HOME: path.join(f.home, ".config") }); + const created = await svc.create({ name: "playwright", type: "local", command: ["playwright-mcp", "--headless"], env: {} }); + const revision = created.revision; + for (const provider of ["claude", "codex", "opencode", "cursor"]) { + await svc.setLink(provider, "playwright", true, revision); + } + const status = await svc.status({ refresh: true }); + for (const provider of ["claude", "codex", "opencode", "cursor"]) { + const row = status.providers.find((entry) => entry.provider === provider); + assert.deepEqual(row.linked, ["playwright"], provider); + assert.deepEqual(row.diverged, [], provider); + } + // A provider-side edit shows as diverged, not linked. + await svc.setLink("claude", "playwright", false, revision); + const unlinked = (await svc.status({ refresh: true })).providers.find((entry) => entry.provider === "claude"); + assert.deepEqual(unlinked.linked, []); + assert.deepEqual(unlinked.unlinked, ["playwright"]); + const grok = (await svc.status()).providers.find((entry) => entry.provider === "grok"); + assert.equal(grok.supported, false); + assert.match(grok.error, /not implemented/); +}); + +test("legacy disabled definitions allow each agent to choose its own state", async (t) => { + const f = await fixture(t); + const svc = svcFor(f); + const created = await svc.create({ name: "quiet", type: "local", enabled: false, command: ["cmd"], env: {} }); + await svc.setLink("codex", "quiet", true, created.revision); + await svc.setLink("opencode", "quiet", true, created.revision); + assert.equal((await svc.list("codex")).shared[0].linked, true); + await svc.setLink("claude", "quiet", true, created.revision); + assert.equal((await svc.list("claude")).shared[0].linked, true); + await assert.rejects(svc.setLink("claude", "quiet", true, created.revision, undefined, false), { code: "unsupported" }); + await assert.rejects(svc.setLink("grok", "quiet", true, created.revision), /not implemented/); +}); + +test("unsupported and unknown MCP shapes stay in place with a warning", async (t) => { + const f = await fixture(t); + const svc = svcFor(f); + await svc.create({ name: "playwright", type: "local", command: ["playwright-mcp"], env: {} }); + await fs.mkdir(path.join(f.home, ".codex"), { recursive: true }); + await fs.writeFile(path.join(f.home, ".codex", "config.toml"), '[mcp_servers.weird]\nstartup_timeout_ms = 5\n'); + const status = await svc.status({ refresh: true }); + const codex = status.providers.find((entry) => entry.provider === "codex"); + assert.match(codex.warning ?? "", /unsupported shape/); + assert.deepEqual(codex.own, [{ name: "weird", recognized: false, extra: ["startup_timeout_ms"] }]); + const list = await svc.list("codex"); + assert.deepEqual(list.unsupported, ["weird"]); + await assert.rejects(svc.list("nope"), { code: "invalid" }); + await assert.rejects(writeProviderMcpEntry(fs, "codex", { home: f.home, env: {} }, "Bad Name", {}), /Invalid MCP server name/); +}); + +test("provider files preserve unrelated keys and survive JSONC", async (t) => { + const f = await fixture(t); + const env = { HOME: f.home, XDG_CONFIG_HOME: path.join(f.home, ".config"), OPENCODE_CONFIG_DIR: path.join(f.home, ".config", "opencode") }; + await fs.mkdir(path.join(f.home, ".config", "opencode"), { recursive: true }); + await fs.writeFile(path.join(f.home, ".config", "opencode", "opencode.jsonc"), '{\n// comment\n"theme": "dark"\n}\n'); + const svc = createMcpService({ home: f.home, env, io: fs, baseDir: f.baseDir }); + const created = await svc.create({ name: "playwright", type: "local", command: ["playwright-mcp"], env: {} }); + await svc.setLink("opencode", "playwright", true, created.revision); + const raw = await fs.readFile(path.join(f.home, ".config", "opencode", "opencode.jsonc"), "utf8"); + assert.match(raw, /comment/); + assert.match(raw, /"theme": "dark"/); + assert.match(raw, /playwright/); + const read = await readProviderMcp(fs, "opencode", { home: f.home, env }); + assert.equal(read.servers.find((row) => row.name === "playwright").canonical.command[0], "playwright-mcp"); +}); + +const ctx = vm.createContext({ URL }); +vm.runInContext(`${readFileSync(new URL("../docker/setup/client/model.js", import.meta.url), "utf8")}\nthis.M = T3Model;`, ctx); +const M = ctx.M; + +test("MCP view model reports shared counts and read errors", () => { + assert.equal(M.mcpView({context:{mcp:{shared:{servers:[{},{}]}}}}).count, 2); + assert.equal(M.mcpView({context:{mcp:{ok:false,shared:{error:"Cannot read store"}}}}).error, "Cannot read store"); + assert.equal(M.mcpView({}).count, 0); +}); diff --git a/tests/persist-credentials.test.sh b/tests/persist-credentials.test.sh index 2b7e2af..388697f 100644 --- a/tests/persist-credentials.test.sh +++ b/tests/persist-credentials.test.sh @@ -52,6 +52,7 @@ printf '%s' 'saved Muse session' > "$T3_HOME/.local/share/muse/session-test" mkdir -p "$T3_HOME/.local/share/opencode" printf '%s' 'OpenCode credential database' > "$T3_HOME/.local/share/opencode/opencode.db" printf '%s' 'OpenCode pending journal' > "$T3_HOME/.local/share/opencode/opencode.db-wal" +printf '%s' '{"mcpServers":{"probe":{"command":"server"}}}' > "$T3_HOME/.claude.json" mkdir -p "$T3_HOME/.agents/skills/local/references" printf '%s' 'Shared skill instructions' > "$T3_HOME/.agents/skills/local/SKILL.md" printf '%s' 'Supporting reference' > "$T3_HOME/.agents/skills/local/references/guide.md" @@ -102,6 +103,7 @@ is "Muse credentials survive a recreate" '{"providers":{"meta":{"api_key":"muse- is "Muse sessions survive a recreate" 'saved Muse session' "$(cat "$T3_HOME/.local/share/muse/session-test")" is "OpenCode credential database survives a recreate" 'OpenCode credential database' "$(cat "$T3_HOME/.local/share/opencode/opencode.db")" is "OpenCode database sidecars survive a recreate" 'OpenCode pending journal' "$(cat "$T3_HOME/.local/share/opencode/opencode.db-wal")" +is "Claude global MCP config survives a recreate" '{"mcpServers":{"probe":{"command":"server"}}}' "$(cat "$T3_HOME/.claude.json")" is "Antigravity CLI skills survive a recreate" 'Antigravity CLI skill' "$(cat "$T3_HOME/.gemini/antigravity-cli/skills/cli-check/SKILL.md")" is "Antigravity skills survive a recreate" 'Antigravity skill' "$(cat "$T3_HOME/.gemini/config/skills/review/SKILL.md")" is "shared skills survive a recreate" 'Shared skill instructions' "$(cat "$T3_HOME/.agents/skills/local/SKILL.md")" @@ -125,6 +127,14 @@ persist_agent_credentials [ ! -L "$T3_HOME/.agents/skills" ] && ok "a mounted .agents parent is respected" || no "a mounted .agents parent is respected" is "mounted shared files remain intact" 'Host skill' "$(cat "$T3_HOME/.agents/skills/mounted/SKILL.md")" +# An absent Claude config must not become a broken persistence link. +rm -rf "$T3_HOME" "$T3CODE_HOME" +mkdir -p "$T3_HOME" "$T3CODE_HOME" +MOUNTED="" +persist_agent_credentials +is "a fresh Claude config starts as valid JSON" '{}' "$(cat "$T3_HOME/.claude.json")" +is "a fresh Claude config is private" '600' "$(stat -c '%a' "$STORE/.claude.json")" + # Turned off: nothing moves. rm -rf "$T3_HOME" "$T3CODE_HOME" mkdir -p "$T3_HOME" "$T3CODE_HOME" diff --git a/tests/t3-client.test.mjs b/tests/t3-client.test.mjs index 570b88a..05ac6ae 100644 --- a/tests/t3-client.test.mjs +++ b/tests/t3-client.test.mjs @@ -100,7 +100,8 @@ test("Setup's commands are found in T3's palette the way T3 finds its own", () = assert.equal(found(">open setup"), "setup"); // Nothing typed, or nothing that answers: none. for (const q of ["", " ", ">", "settings", "new thread", "setup ports zzz", "theme"]) assert.equal(found(q), "", q); - for (const item of B.PALETTE_ITEMS) assert.ok(item.route === null || /^[a-z]+$/.test(item.route), item.id); + assert.equal(found('mcp').split(',')[0], 'mcp'); + for (const item of B.PALETTE_ITEMS) assert.ok(item.route === null || /^[a-z]+(?:\/mcp)?$/.test(item.route), item.id); }); test("arrow keys walk Setup's rows and T3's as one list that wraps", () => {