gigarag-claude-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +10 -0
- package/.mcp.json +8 -0
- package/README.md +34 -0
- package/agents/gigarag-indexer.md +72 -0
- package/bin/gigarag +4 -0
- package/cli/auth/credentials.js +106 -0
- package/cli/auth/oauth.js +203 -0
- package/cli/auth/page.js +68 -0
- package/cli/bin.js +8 -0
- package/cli/cli.js +90 -0
- package/cli/clients/commands.js +160 -0
- package/cli/clients/connect.js +217 -0
- package/cli/clients/inspect.js +74 -0
- package/cli/clients/json.js +135 -0
- package/cli/clients/launcher.js +71 -0
- package/cli/clients/registry.js +40 -0
- package/cli/clients/toml.js +169 -0
- package/cli/clients/tomlarray.js +121 -0
- package/cli/clients/yaml.js +146 -0
- package/cli/clients.json +1226 -0
- package/cli/commands/authHeader.js +22 -0
- package/cli/commands/connect.js +285 -0
- package/cli/commands/indexSync.js +46 -0
- package/cli/commands/login.js +129 -0
- package/cli/commands/mcp.js +22 -0
- package/cli/commands/record.js +72 -0
- package/cli/commands/repo.js +48 -0
- package/cli/commands/scan.js +72 -0
- package/cli/commands/status.js +115 -0
- package/cli/config.js +69 -0
- package/cli/connect.js +8 -0
- package/cli/constants.js +24 -0
- package/cli/hooks.js +151 -0
- package/cli/index.js +3 -0
- package/cli/mcp/bridge.js +123 -0
- package/cli/mcp/client.js +156 -0
- package/cli/mcp/session.js +79 -0
- package/cli/package.json +5 -0
- package/cli/paths.js +34 -0
- package/cli/prompts.generated.js +44 -0
- package/cli/prompts.js +48 -0
- package/cli/scan/chunk.js +43 -0
- package/cli/scan/ignore.js +117 -0
- package/cli/scan/repo.js +99 -0
- package/cli/scan/scan.js +262 -0
- package/cli/scan.js +5 -0
- package/cli/sdk.js +130 -0
- package/cli/secrets.js +192 -0
- package/cli/secureUrl.js +18 -0
- package/cli/state.js +210 -0
- package/cli/ui.js +66 -0
- package/commands/gigadocs.md +19 -0
- package/commands/gigaindex.md +10 -0
- package/commands/gigarecall.md +15 -0
- package/commands/gigasave.md +24 -0
- package/commands/gigasync.md +9 -0
- package/hooks/hooks.json +38 -0
- package/package.json +26 -0
- package/scripts/run.mjs +64 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { PROMPTS } from '../prompts.js';
|
|
4
|
+
import { expandPath } from '../paths.js';
|
|
5
|
+
/** Every file we write carries this, so we only ever replace or delete our own. */
|
|
6
|
+
export const MARKER = 'gigarag:managed';
|
|
7
|
+
const FALLBACK_ARGS = 'the text the user typed after the command';
|
|
8
|
+
function body(prompt, target) {
|
|
9
|
+
return prompt.text.replaceAll('$ARGUMENTS', () => target.argToken ?? FALLBACK_ARGS);
|
|
10
|
+
}
|
|
11
|
+
function yamlValue(v) {
|
|
12
|
+
if (typeof v === 'boolean')
|
|
13
|
+
return String(v);
|
|
14
|
+
return /^[\w .,()/'-]+$/.test(v) && !/^\s|\s$|^(true|false|null|yes|no)$/i.test(v) ? v : JSON.stringify(v);
|
|
15
|
+
}
|
|
16
|
+
function tomlString(text) {
|
|
17
|
+
return `"""\n${text.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"')}"""`;
|
|
18
|
+
}
|
|
19
|
+
/** The file for one prompt: where it goes under the target directory, and what it says. */
|
|
20
|
+
export function renderCommand(target, prompt) {
|
|
21
|
+
const text = body(prompt, target);
|
|
22
|
+
if (target.kind === 'skill') {
|
|
23
|
+
const extra = Object.entries(target.frontmatter ?? {}).map(([k, v]) => `${k}: ${yamlValue(v)}`);
|
|
24
|
+
const fm = [`name: ${prompt.name}`, `description: ${yamlValue(prompt.description)}`, ...extra];
|
|
25
|
+
return {
|
|
26
|
+
rel: join(prompt.name, 'SKILL.md'),
|
|
27
|
+
text: `---\n${fm.join('\n')}\n---\n\n${text}\n\n<!-- ${MARKER} -->\n`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (target.kind === 'toml') {
|
|
31
|
+
return {
|
|
32
|
+
rel: `${prompt.name}.toml`,
|
|
33
|
+
text: `# ${MARKER}\ndescription = ${JSON.stringify(prompt.description)}\nprompt = ${tomlString(text)}\n`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const fm = [];
|
|
37
|
+
if (target.nameKey)
|
|
38
|
+
fm.push(`name: ${prompt.name}`);
|
|
39
|
+
fm.push(`description: ${yamlValue(prompt.description)}`);
|
|
40
|
+
if (target.argumentHint && prompt.argumentHint)
|
|
41
|
+
fm.push(`argument-hint: ${yamlValue(prompt.argumentHint)}`);
|
|
42
|
+
for (const [k, v] of Object.entries(target.frontmatter ?? {}))
|
|
43
|
+
fm.push(`${k}: ${yamlValue(v)}`);
|
|
44
|
+
return {
|
|
45
|
+
rel: `${prompt.name}.md`,
|
|
46
|
+
text: `---\n${fm.join('\n')}\n---\n\n${text}\n\n<!-- ${MARKER} -->\n`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function targetDir(target, where) {
|
|
50
|
+
const raw = target.path[where.platform] ?? target.path['*'];
|
|
51
|
+
return raw ? expandPath(raw, where.platform, where.env, where.home) : undefined;
|
|
52
|
+
}
|
|
53
|
+
const isOurs = (text) => text.includes(MARKER);
|
|
54
|
+
function writeFile(path, text) {
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
56
|
+
writeFileSync(path, text);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Writes every command into one target directory. It never touches a file that
|
|
60
|
+
* lacks the marker: a person's own `gigasave.md` is theirs, and the result says
|
|
61
|
+
* so and leaves it.
|
|
62
|
+
*/
|
|
63
|
+
export function installCommands(targetId, target, where, prompts = PROMPTS) {
|
|
64
|
+
const dir = targetDir(target, where);
|
|
65
|
+
if (!dir)
|
|
66
|
+
return [];
|
|
67
|
+
const results = [];
|
|
68
|
+
for (const prompt of prompts) {
|
|
69
|
+
const { rel, text } = renderCommand(target, prompt);
|
|
70
|
+
const path = join(dir, rel);
|
|
71
|
+
const base = { target: targetId, name: prompt.name, path };
|
|
72
|
+
try {
|
|
73
|
+
if (!existsSync(path)) {
|
|
74
|
+
writeFile(path, text);
|
|
75
|
+
results.push({ ...base, status: 'written' });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const current = readFileSync(path, 'utf8');
|
|
79
|
+
if (!isOurs(current)) {
|
|
80
|
+
results.push({ ...base, status: 'skipped', reason: 'a file of yours with that name is already there' });
|
|
81
|
+
}
|
|
82
|
+
else if (current.replace(/\r\n/g, '\n') === text) {
|
|
83
|
+
results.push({ ...base, status: 'unchanged' });
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
writeFile(path, text);
|
|
87
|
+
results.push({ ...base, status: 'replaced' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
results.push({ ...base, status: 'skipped', reason: err.message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
/** Deletes our files from a target, and a skill folder once it is empty. Anything without the marker stays. */
|
|
97
|
+
export function removeCommands(targetId, target, where, prompts = PROMPTS) {
|
|
98
|
+
const dir = targetDir(target, where);
|
|
99
|
+
if (!dir)
|
|
100
|
+
return [];
|
|
101
|
+
const results = [];
|
|
102
|
+
for (const prompt of prompts) {
|
|
103
|
+
const { rel } = renderCommand(target, prompt);
|
|
104
|
+
const path = join(dir, rel);
|
|
105
|
+
const base = { target: targetId, name: prompt.name, path };
|
|
106
|
+
if (!existsSync(path))
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
if (!isOurs(readFileSync(path, 'utf8'))) {
|
|
110
|
+
results.push({ ...base, status: 'skipped', reason: 'not written by gigarag' });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
unlinkSync(path);
|
|
114
|
+
if (target.kind === 'skill') {
|
|
115
|
+
const folder = dirname(path);
|
|
116
|
+
if (readdirSync(folder).length === 0)
|
|
117
|
+
rmdirSync(folder);
|
|
118
|
+
}
|
|
119
|
+
results.push({ ...base, status: 'removed' });
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
results.push({ ...base, status: 'skipped', reason: err.message });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
/** How many of our commands are installed in a target right now, for `status`. */
|
|
128
|
+
export function installedCount(target, where, prompts = PROMPTS) {
|
|
129
|
+
const dir = targetDir(target, where);
|
|
130
|
+
if (!dir)
|
|
131
|
+
return 0;
|
|
132
|
+
return prompts.filter(p => {
|
|
133
|
+
const path = join(dir, renderCommand(target, p).rel);
|
|
134
|
+
try {
|
|
135
|
+
return existsSync(path) && isOurs(readFileSync(path, 'utf8'));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}).length;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Which command targets a set of clients needs. A target several clients read,
|
|
144
|
+
* such as the shared skills folder, appears once with all of them attached, so
|
|
145
|
+
* it is written once and removed only when the last of them goes.
|
|
146
|
+
*/
|
|
147
|
+
export function planTargets(registry, clients) {
|
|
148
|
+
const plan = new Map();
|
|
149
|
+
for (const client of clients) {
|
|
150
|
+
for (const id of client.commands ?? []) {
|
|
151
|
+
const target = registry.commandTargets[id];
|
|
152
|
+
if (!target)
|
|
153
|
+
continue;
|
|
154
|
+
const entry = plan.get(id) ?? { target, clients: [] };
|
|
155
|
+
entry.clients.push(client);
|
|
156
|
+
plan.set(id, entry);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return plan;
|
|
160
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { SERVER_NAME } from '../constants.js';
|
|
4
|
+
import { fillEntry } from './launcher.js';
|
|
5
|
+
import { configPathFor } from './registry.js';
|
|
6
|
+
import { parseJsonConfig, removeEntry, serializeJson, upsertEntry } from './json.js';
|
|
7
|
+
import { removeTable, setTable } from './toml.js';
|
|
8
|
+
import { removeArrayEntry, setArrayEntry } from './tomlarray.js';
|
|
9
|
+
import { removeYamlEntry, setYamlEntry } from './yaml.js';
|
|
10
|
+
/** Every whole file we write carries this, so we only ever replace or delete our own. */
|
|
11
|
+
export const FILE_MARKER = 'gigarag:managed';
|
|
12
|
+
const backupPath = (path) => `${path}.gigarag.bak`;
|
|
13
|
+
/**
|
|
14
|
+
* Written next to the target and renamed over it, so a crash never leaves half a config.
|
|
15
|
+
*
|
|
16
|
+
* Two things a plain temp file and rename would lose. The file's permissions: a config that holds
|
|
17
|
+
* other tools' secrets is often 0600, and a new file would come out 0644. And a symlink: a dotfiles
|
|
18
|
+
* manager links `~/.cursor/mcp.json` elsewhere, and renaming over the link replaces it with a regular
|
|
19
|
+
* file. So the write goes to the link's target and takes the original's mode.
|
|
20
|
+
*/
|
|
21
|
+
function writeAtomic(path, text) {
|
|
22
|
+
let target = path;
|
|
23
|
+
let mode;
|
|
24
|
+
if (existsSync(path)) {
|
|
25
|
+
if (lstatSync(path).isSymbolicLink())
|
|
26
|
+
target = realpathSync(path);
|
|
27
|
+
mode = statSync(target).mode & 0o777;
|
|
28
|
+
}
|
|
29
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
30
|
+
const tmp = `${target}.gigarag-${process.pid}.tmp`;
|
|
31
|
+
writeFileSync(tmp, text, mode === undefined ? undefined : { mode });
|
|
32
|
+
try {
|
|
33
|
+
renameSync(tmp, target);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
try {
|
|
37
|
+
unlinkSync(tmp);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* nothing to clean */
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Copies the file once. A second backup would overwrite the first, which is the
|
|
47
|
+
* copy that holds the person's config from before we ever touched it.
|
|
48
|
+
*/
|
|
49
|
+
function backupOnce(path) {
|
|
50
|
+
const target = backupPath(path);
|
|
51
|
+
if (!existsSync(path) || existsSync(target))
|
|
52
|
+
return undefined;
|
|
53
|
+
copyFileSync(path, target);
|
|
54
|
+
return target;
|
|
55
|
+
}
|
|
56
|
+
export function yamlFile(launcher) {
|
|
57
|
+
const q = (s) => JSON.stringify(s);
|
|
58
|
+
return [
|
|
59
|
+
`# ${FILE_MARKER}`,
|
|
60
|
+
'name: GigaRAG',
|
|
61
|
+
'version: 0.0.1',
|
|
62
|
+
'schema: v1',
|
|
63
|
+
'mcpServers:',
|
|
64
|
+
` - name: ${SERVER_NAME}`,
|
|
65
|
+
` command: ${q(launcher.command)}`,
|
|
66
|
+
' args:',
|
|
67
|
+
...launcher.args.map(a => ` - ${q(a)}`),
|
|
68
|
+
'',
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
function tomlValues(entry) {
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
74
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
|
|
75
|
+
out[k] = v;
|
|
76
|
+
else if (Array.isArray(v) && v.every(x => typeof x === 'string'))
|
|
77
|
+
out[k] = v;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
const textOf = (edit) => (edit.ok && 'text' in edit && edit.text !== undefined ? edit.text : '');
|
|
82
|
+
/** The block for `connect --print`: what would be written, in the client's own format. */
|
|
83
|
+
export function renderConfig(client, launcher, url) {
|
|
84
|
+
const cfg = client.config;
|
|
85
|
+
if (!cfg) {
|
|
86
|
+
return JSON.stringify({ mcpServers: { [SERVER_NAME]: { command: launcher.command, args: launcher.args } } }, null, 2);
|
|
87
|
+
}
|
|
88
|
+
const entry = fillEntry(cfg.entry, launcher, url);
|
|
89
|
+
switch (cfg.format) {
|
|
90
|
+
case 'yamlfile':
|
|
91
|
+
return yamlFile(launcher);
|
|
92
|
+
case 'toml':
|
|
93
|
+
return textOf(setTable('', [...cfg.at, SERVER_NAME], tomlValues(entry)));
|
|
94
|
+
case 'toml-array':
|
|
95
|
+
return textOf(setArrayEntry('', cfg.at, SERVER_NAME, tomlValues(entry)));
|
|
96
|
+
case 'yamlmap':
|
|
97
|
+
return textOf(setYamlEntry('', cfg.at[0], SERVER_NAME, entry));
|
|
98
|
+
default: {
|
|
99
|
+
let wrapped = { [SERVER_NAME]: entry };
|
|
100
|
+
for (const key of [...cfg.at].reverse())
|
|
101
|
+
wrapped = { [key]: wrapped };
|
|
102
|
+
return JSON.stringify(wrapped, null, 2);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Adds or replaces the gigarag entry in one client's config. Never throws: the result says what happened. */
|
|
107
|
+
export function connectClient(client, where, launcher, url) {
|
|
108
|
+
const base = { slug: client.slug, name: client.name };
|
|
109
|
+
const cfg = client.config;
|
|
110
|
+
const path = configPathFor(client, where);
|
|
111
|
+
if (client.mode !== 'auto' || !cfg || !path) {
|
|
112
|
+
return { ...base, status: 'skipped', reason: client.hint ?? 'set up by hand; use --print' };
|
|
113
|
+
}
|
|
114
|
+
const refuse = (reason) => ({
|
|
115
|
+
...base,
|
|
116
|
+
status: 'failed',
|
|
117
|
+
path,
|
|
118
|
+
reason: `${path} was left alone: ${reason}. Run: gigarag connect --print ${client.slug}`,
|
|
119
|
+
});
|
|
120
|
+
try {
|
|
121
|
+
const exists = existsSync(path);
|
|
122
|
+
const original = exists ? readFileSync(path, 'utf8') : '';
|
|
123
|
+
const entry = fillEntry(cfg.entry, launcher, url);
|
|
124
|
+
let edit;
|
|
125
|
+
if (cfg.format === 'yamlfile') {
|
|
126
|
+
// The whole file is ours or it is not. A file of the person's with the same name is theirs.
|
|
127
|
+
if (exists && !original.includes(FILE_MARKER))
|
|
128
|
+
return refuse('a file of yours is already there and it is not one gigarag wrote');
|
|
129
|
+
const next = yamlFile(launcher);
|
|
130
|
+
edit = { ok: true, text: next, existed: exists, changed: !exists || original.replace(/\r\n/g, '\n') !== next };
|
|
131
|
+
}
|
|
132
|
+
else if (cfg.format === 'toml') {
|
|
133
|
+
edit = setTable(original, [...cfg.at, SERVER_NAME], tomlValues(entry));
|
|
134
|
+
}
|
|
135
|
+
else if (cfg.format === 'toml-array') {
|
|
136
|
+
edit = setArrayEntry(original, cfg.at, SERVER_NAME, tomlValues(entry));
|
|
137
|
+
}
|
|
138
|
+
else if (cfg.format === 'yamlmap') {
|
|
139
|
+
edit = setYamlEntry(original, cfg.at[0], SERVER_NAME, entry);
|
|
140
|
+
}
|
|
141
|
+
if (edit) {
|
|
142
|
+
if (!edit.ok)
|
|
143
|
+
return refuse(edit.reason);
|
|
144
|
+
if (!edit.changed)
|
|
145
|
+
return { ...base, status: 'unchanged', path };
|
|
146
|
+
const backup = backupOnce(path);
|
|
147
|
+
writeAtomic(path, edit.text);
|
|
148
|
+
return { ...base, status: edit.existed ? 'replaced' : 'written', path, backup };
|
|
149
|
+
}
|
|
150
|
+
const parsed = parseJsonConfig(original);
|
|
151
|
+
if (!parsed.ok)
|
|
152
|
+
return refuse(parsed.reason);
|
|
153
|
+
const outcome = upsertEntry(parsed.value, cfg.at, SERVER_NAME, entry);
|
|
154
|
+
if (typeof outcome === 'object')
|
|
155
|
+
return refuse(outcome.error);
|
|
156
|
+
if (outcome === 'unchanged')
|
|
157
|
+
return { ...base, status: 'unchanged', path };
|
|
158
|
+
const backup = backupOnce(path);
|
|
159
|
+
writeAtomic(path, serializeJson(parsed));
|
|
160
|
+
return { ...base, status: outcome === 'added' ? 'written' : 'replaced', path, backup };
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return { ...base, status: 'failed', path, reason: err.message };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Deletes our entry and nothing else. */
|
|
167
|
+
export function removeClientEntry(client, where) {
|
|
168
|
+
const base = { slug: client.slug, name: client.name };
|
|
169
|
+
const cfg = client.config;
|
|
170
|
+
const path = configPathFor(client, where);
|
|
171
|
+
if (!cfg || !path || !existsSync(path))
|
|
172
|
+
return { ...base, status: 'skipped', reason: 'no config file' };
|
|
173
|
+
try {
|
|
174
|
+
const original = readFileSync(path, 'utf8');
|
|
175
|
+
if (cfg.format === 'yamlfile') {
|
|
176
|
+
// Only a file we wrote. One the person edited or replaced is theirs, and stays.
|
|
177
|
+
if (!original.includes(FILE_MARKER))
|
|
178
|
+
return { ...base, status: 'skipped', path, reason: 'not written by gigarag' };
|
|
179
|
+
unlinkSync(path);
|
|
180
|
+
return { ...base, status: 'written', path, reason: 'file deleted' };
|
|
181
|
+
}
|
|
182
|
+
let next;
|
|
183
|
+
if (cfg.format === 'toml') {
|
|
184
|
+
const r = removeTable(original, [...cfg.at, SERVER_NAME]);
|
|
185
|
+
next = r.ok ? { ok: true, text: r.text, removed: r.existed } : r;
|
|
186
|
+
}
|
|
187
|
+
else if (cfg.format === 'toml-array') {
|
|
188
|
+
const r = removeArrayEntry(original, cfg.at, SERVER_NAME);
|
|
189
|
+
next = r.ok ? { ok: true, text: r.text, removed: r.existed } : r;
|
|
190
|
+
}
|
|
191
|
+
else if (cfg.format === 'yamlmap') {
|
|
192
|
+
const r = removeYamlEntry(original, cfg.at[0], SERVER_NAME);
|
|
193
|
+
next = r.ok ? { ok: true, text: r.text, removed: r.existed } : r;
|
|
194
|
+
}
|
|
195
|
+
if (next) {
|
|
196
|
+
if (!next.ok)
|
|
197
|
+
return { ...base, status: 'failed', path, reason: `${path} was left alone: ${next.reason}` };
|
|
198
|
+
if (!next.removed)
|
|
199
|
+
return { ...base, status: 'skipped', path, reason: 'no gigarag entry' };
|
|
200
|
+
backupOnce(path);
|
|
201
|
+
writeAtomic(path, next.text);
|
|
202
|
+
return { ...base, status: 'written', path };
|
|
203
|
+
}
|
|
204
|
+
const parsed = parseJsonConfig(original);
|
|
205
|
+
if (!parsed.ok)
|
|
206
|
+
return { ...base, status: 'failed', path, reason: `${path} was left alone: ${parsed.reason}` };
|
|
207
|
+
if (!removeEntry(parsed.value, cfg.at, SERVER_NAME)) {
|
|
208
|
+
return { ...base, status: 'skipped', path, reason: 'no gigarag entry' };
|
|
209
|
+
}
|
|
210
|
+
backupOnce(path);
|
|
211
|
+
writeAtomic(path, serializeJson(parsed));
|
|
212
|
+
return { ...base, status: 'written', path };
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
return { ...base, status: 'failed', path, reason: err.message };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { SERVER_NAME } from '../constants.js';
|
|
3
|
+
import { configPathFor } from './registry.js';
|
|
4
|
+
import { parseJsonConfig } from './json.js';
|
|
5
|
+
import { headerParts } from './toml.js';
|
|
6
|
+
import { arrayEntryCommand } from './tomlarray.js';
|
|
7
|
+
const QUOTED_COMMAND = /^\s*(?:command|cmd)\s*[:=]\s*("(?:[^"\\]|\\.)*")/;
|
|
8
|
+
const indentOf = (line) => /^ */.exec(line)[0].length;
|
|
9
|
+
/** The command our entry launches, read back from the file. Undefined when there is no entry. */
|
|
10
|
+
export function installedCommand(client, where) {
|
|
11
|
+
const cfg = client.config;
|
|
12
|
+
const path = configPathFor(client, where);
|
|
13
|
+
if (!cfg || !path || !existsSync(path))
|
|
14
|
+
return undefined;
|
|
15
|
+
try {
|
|
16
|
+
const text = readFileSync(path, 'utf8');
|
|
17
|
+
const lines = text.split(/\r?\n/);
|
|
18
|
+
if (cfg.format === 'yamlfile') {
|
|
19
|
+
const line = lines.map(l => QUOTED_COMMAND.exec(l)).find(Boolean);
|
|
20
|
+
return line ? JSON.parse(line[1]) : undefined;
|
|
21
|
+
}
|
|
22
|
+
if (cfg.format === 'toml-array')
|
|
23
|
+
return arrayEntryCommand(text, cfg.at, SERVER_NAME);
|
|
24
|
+
if (cfg.format === 'yamlmap') {
|
|
25
|
+
const at = lines.findIndex(l => l.trim() === `${SERVER_NAME}:` && indentOf(l) > 0);
|
|
26
|
+
if (at < 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
const indent = indentOf(lines[at]);
|
|
29
|
+
for (const line of lines.slice(at + 1)) {
|
|
30
|
+
if (line.trim() !== '' && indentOf(line) <= indent)
|
|
31
|
+
break;
|
|
32
|
+
const m = QUOTED_COMMAND.exec(line);
|
|
33
|
+
if (m)
|
|
34
|
+
return JSON.parse(m[1]);
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (cfg.format === 'toml') {
|
|
39
|
+
const want = [...cfg.at, SERVER_NAME].join('.');
|
|
40
|
+
const start = lines.findIndex(l => headerParts(l)?.join('.') === want);
|
|
41
|
+
if (start < 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
for (const line of lines.slice(start + 1)) {
|
|
44
|
+
if (/^\s*\[/.test(line))
|
|
45
|
+
break;
|
|
46
|
+
const m = QUOTED_COMMAND.exec(line);
|
|
47
|
+
if (m)
|
|
48
|
+
return JSON.parse(m[1]);
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const parsed = parseJsonConfig(text);
|
|
53
|
+
if (!parsed.ok)
|
|
54
|
+
return undefined;
|
|
55
|
+
let node = parsed.value;
|
|
56
|
+
for (const key of cfg.at)
|
|
57
|
+
node = node?.[key];
|
|
58
|
+
const command = node?.[SERVER_NAME]?.['command'];
|
|
59
|
+
if (typeof command === 'string')
|
|
60
|
+
return command;
|
|
61
|
+
if (Array.isArray(command) && typeof command[0] === 'string')
|
|
62
|
+
return command[0];
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The command we wrote, when it points at a file that is gone. */
|
|
70
|
+
export function launcherIsStale(client, where) {
|
|
71
|
+
// A command written for a shell-splitting client comes back with its quotes on.
|
|
72
|
+
const command = installedCommand(client, where)?.replace(/^"(.*)"$/, '$1');
|
|
73
|
+
return command && /[\\/]/.test(command) && !existsSync(command) ? command : undefined;
|
|
74
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** True when stripping comments and trailing commas is what makes the text parse. */
|
|
2
|
+
function isJsonc(text) {
|
|
3
|
+
let out = '';
|
|
4
|
+
let inString = false;
|
|
5
|
+
for (let i = 0; i < text.length; i++) {
|
|
6
|
+
const ch = text[i];
|
|
7
|
+
if (inString) {
|
|
8
|
+
out += ch;
|
|
9
|
+
if (ch === '\\')
|
|
10
|
+
out += text[++i] ?? '';
|
|
11
|
+
else if (ch === '"')
|
|
12
|
+
inString = false;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (ch === '"') {
|
|
16
|
+
inString = true;
|
|
17
|
+
out += ch;
|
|
18
|
+
}
|
|
19
|
+
else if (ch === '/' && text[i + 1] === '/') {
|
|
20
|
+
while (i < text.length && text[i] !== '\n')
|
|
21
|
+
i++;
|
|
22
|
+
out += '\n';
|
|
23
|
+
}
|
|
24
|
+
else if (ch === '/' && text[i + 1] === '*') {
|
|
25
|
+
i += 2;
|
|
26
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
|
|
27
|
+
i++;
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
out += ch;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* True when a number in the text would come out different after a parse and a write: an integer past
|
|
44
|
+
* 2^53, or one too large to represent. Rewriting the file would quietly change somebody's data.
|
|
45
|
+
*/
|
|
46
|
+
function hasLossyNumber(text) {
|
|
47
|
+
const outsideStrings = text.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
48
|
+
for (const token of outsideStrings.match(/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g) ?? []) {
|
|
49
|
+
const n = Number(token);
|
|
50
|
+
if (!Number.isFinite(n))
|
|
51
|
+
return true;
|
|
52
|
+
if (/^-?\d+$/.test(token) && String(n) !== token.replace(/^(-?)0+(?=\d)/, '$1'))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parses a config file for editing. An empty file is an empty object. A file
|
|
59
|
+
* with comments is refused rather than rewritten, because rewriting it as plain
|
|
60
|
+
* JSON would delete every comment the person wrote.
|
|
61
|
+
*/
|
|
62
|
+
export function parseJsonConfig(text) {
|
|
63
|
+
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
64
|
+
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
|
65
|
+
const endsWithNewline = body.endsWith('\n') || body.trim() === '';
|
|
66
|
+
if (body.trim() === '')
|
|
67
|
+
return { ok: true, value: {}, indent: ' ', eol, endsWithNewline: true };
|
|
68
|
+
let value;
|
|
69
|
+
try {
|
|
70
|
+
value = JSON.parse(body);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: isJsonc(body)
|
|
76
|
+
? 'it contains comments or trailing commas, and rewriting it would remove them'
|
|
77
|
+
: `it is not valid JSON (${err.message})`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
81
|
+
return { ok: false, reason: 'its top level is not an object' };
|
|
82
|
+
}
|
|
83
|
+
if (hasLossyNumber(body))
|
|
84
|
+
return { ok: false, reason: 'it holds a number too large to write back without changing it' };
|
|
85
|
+
const indentMatch = /\n([ \t]+)"/.exec(body);
|
|
86
|
+
return { ok: true, value: value, indent: indentMatch?.[1] ?? ' ', eol, endsWithNewline };
|
|
87
|
+
}
|
|
88
|
+
export function serializeJson(parsed) {
|
|
89
|
+
let out = JSON.stringify(parsed.value, null, parsed.indent);
|
|
90
|
+
if (parsed.eol === '\r\n')
|
|
91
|
+
out = out.replace(/\n/g, '\r\n');
|
|
92
|
+
return parsed.endsWithNewline ? `${out}${parsed.eol}` : out;
|
|
93
|
+
}
|
|
94
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
95
|
+
/** Sets `root[...at][name] = entry`, creating the containers on the way. */
|
|
96
|
+
export function upsertEntry(root, at, name, entry) {
|
|
97
|
+
let node = root;
|
|
98
|
+
for (const key of at) {
|
|
99
|
+
const next = node[key];
|
|
100
|
+
if (next === undefined) {
|
|
101
|
+
const created = {};
|
|
102
|
+
node[key] = created;
|
|
103
|
+
node = created;
|
|
104
|
+
}
|
|
105
|
+
else if (next !== null && typeof next === 'object' && !Array.isArray(next)) {
|
|
106
|
+
node = next;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
return { error: `"${key}" is not an object in that file` };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const existing = node[name];
|
|
113
|
+
if (existing === undefined) {
|
|
114
|
+
node[name] = entry;
|
|
115
|
+
return 'added';
|
|
116
|
+
}
|
|
117
|
+
if (same(existing, entry))
|
|
118
|
+
return 'unchanged';
|
|
119
|
+
node[name] = entry;
|
|
120
|
+
return 'replaced';
|
|
121
|
+
}
|
|
122
|
+
/** Deletes `root[...at][name]`. Returns whether anything was there. */
|
|
123
|
+
export function removeEntry(root, at, name) {
|
|
124
|
+
let node = root;
|
|
125
|
+
for (const key of at) {
|
|
126
|
+
const next = node[key];
|
|
127
|
+
if (next === null || typeof next !== 'object' || Array.isArray(next))
|
|
128
|
+
return false;
|
|
129
|
+
node = next;
|
|
130
|
+
}
|
|
131
|
+
if (!(name in node))
|
|
132
|
+
return false;
|
|
133
|
+
delete node[name];
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { sep } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { isSea } from 'node:sea';
|
|
4
|
+
import { version } from '../constants.js';
|
|
5
|
+
/**
|
|
6
|
+
* Picks the command a client will run.
|
|
7
|
+
*
|
|
8
|
+
* Absolute paths, not `gigarag` or `npx`: an editor launched from a dock or a
|
|
9
|
+
* start menu does not inherit the shell PATH, so a bare command name works in
|
|
10
|
+
* a terminal test and fails inside the app. The cost is that moving Node
|
|
11
|
+
* invalidates the entry, which `gigarag status` reports and re-running
|
|
12
|
+
* `gigarag connect` fixes.
|
|
13
|
+
*
|
|
14
|
+
* Running through npx is the exception. Its cache path is not stable, so there
|
|
15
|
+
* the entry itself calls npx, wrapped in cmd on Windows where npx is a script.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveLauncher(force, binScript = fileURLToPath(new URL('../bin.js', import.meta.url)), platform = process.platform, execPath = process.execPath, sea = isSea()) {
|
|
18
|
+
const inNpxCache = binScript.split(sep).includes('_npx');
|
|
19
|
+
const kind = force ?? (sea ? 'binary' : inNpxCache ? 'npx' : 'node');
|
|
20
|
+
// Only the standalone build is a binary that takes `mcp` directly. Anywhere else execPath is node itself,
|
|
21
|
+
// and `node mcp` would look for a script called mcp.
|
|
22
|
+
if (kind === 'binary' && sea)
|
|
23
|
+
return { command: execPath, args: ['mcp'], kind };
|
|
24
|
+
if (kind === 'npx') {
|
|
25
|
+
// The version this was configured by, so a client keeps running the CLI it was set up with until
|
|
26
|
+
// `gigarag connect` is run again, and a release cannot change a running setup unannounced.
|
|
27
|
+
const v = version();
|
|
28
|
+
const spec = v === '0.0.0' ? 'gigarag' : `gigarag@${v}`;
|
|
29
|
+
return platform === 'win32'
|
|
30
|
+
? { command: 'cmd', args: ['/c', 'npx', '-y', spec, 'mcp'], kind }
|
|
31
|
+
: { command: 'npx', args: ['-y', spec, 'mcp'], kind };
|
|
32
|
+
}
|
|
33
|
+
return { command: execPath, args: [binScript, 'mcp'], kind: 'node' };
|
|
34
|
+
}
|
|
35
|
+
const quote = (part) => (/[\s"]/.test(part) ? `"${part.replaceAll('"', '\\"')}"` : part);
|
|
36
|
+
/** The shell command a `headersHelper` runs: the launcher with `auth-header` in place of `mcp`. */
|
|
37
|
+
export function helperCommand(launcher) {
|
|
38
|
+
const args = launcher.args.map(a => (a === 'mcp' ? 'auth-header' : a));
|
|
39
|
+
return [launcher.command, ...args].map(quote).join(' ');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A command for a client that splits it like a POSIX shell. Mistral Vibe does: given
|
|
43
|
+
* `C:\Program Files\nodejs\node.exe` it started `C:Program` and `Filesnodejsnode.exe`, so
|
|
44
|
+
* the backslashes were eaten and the space cut the path in two. Forward slashes survive that,
|
|
45
|
+
* Windows accepts them, and quotes keep a path with spaces in one piece.
|
|
46
|
+
*/
|
|
47
|
+
export function posixCommand(command) {
|
|
48
|
+
const slashed = command.replace(/\\/g, '/');
|
|
49
|
+
return /\s/.test(slashed) ? `"${slashed}"` : slashed;
|
|
50
|
+
}
|
|
51
|
+
/** Fills `$command`, `$commandPosix`, `$args`, `$argv`, `$helper` and `$url` in a registry entry. */
|
|
52
|
+
export function fillEntry(entry, launcher, url = 'https://mcp.gigarag.com/mcp') {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
55
|
+
if (value === '$command')
|
|
56
|
+
out[key] = launcher.command;
|
|
57
|
+
else if (value === '$args')
|
|
58
|
+
out[key] = [...launcher.args];
|
|
59
|
+
else if (value === '$argv')
|
|
60
|
+
out[key] = [launcher.command, ...launcher.args];
|
|
61
|
+
else if (value === '$commandPosix')
|
|
62
|
+
out[key] = posixCommand(launcher.command);
|
|
63
|
+
else if (value === '$helper')
|
|
64
|
+
out[key] = helperCommand(launcher);
|
|
65
|
+
else if (value === '$url')
|
|
66
|
+
out[key] = url;
|
|
67
|
+
else
|
|
68
|
+
out[key] = value;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|