sekura 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,13 +36,13 @@ npx sekura@latest uninstall # remove the entries, the skill and the
36
36
  | GitHub Copilot (VS Code) | the VS Code user `mcp.json` → `servers.sekura` | `.mcp.json` |
37
37
  | OpenAI Codex | `codex mcp add sekura --url …`, or `~/.codex/config.toml` → `[mcp_servers.sekura]` | — |
38
38
  | Kiro | `~/.kiro/settings/mcp.json` | `.kiro/settings/mcp.json` |
39
- | Cline | `cline_mcp_settings.json` in VS Code's global storage | — |
39
+ | Cline | `~/.cline/data/settings/cline_mcp_settings.json` (shared by the extension, v4 and later, and the Cline CLI) | — |
40
40
  | Zed | `~/.config/zed/settings.json` → `context_servers.sekura` (shown by hand when the file has comments) | — |
41
41
  | Antigravity | `~/.gemini/config/mcp_config.json` → `serverUrl` | `.agents/mcp_config.json` |
42
42
  | OpenCode | `~/.config/opencode/opencode.json` → `mcp.sekura` | `opencode.json` |
43
43
  | JetBrains Junie | — | `.junie/mcp/mcp.json` |
44
44
  | Factory Droid, Tabnine, Devin | the tool's own `mcp add` command | — |
45
- | Goose | a link to the page | — |
45
+ | Goose | `~/.config/goose/config.yaml` → `extensions.sekura` (`streamable_http`) | — |
46
46
 
47
47
  The skill goes to `~/.agents/skills/sekura/SKILL.md` and, when Claude Code is present, `~/.claude/skills/sekura/SKILL.md`; in project scope to the same paths under the project, plus a section between `<!-- sekura:start -->` and `<!-- sekura:end -->` in `AGENTS.md` (and `CLAUDE.md` if one exists). Reruns replace only what sits between the markers.
48
48
 
package/dist/catalogue.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // A tool is "present" when its config directory exists or its binary is on
10
10
  // PATH. Presence decides what the wizard offers, never what it writes: the
11
11
  // person chooses, and a tool they did not choose is not touched.
12
- import { existsSync } from 'node:fs';
12
+ import { existsSync, readdirSync } from 'node:fs';
13
13
  import { homedir, platform } from 'node:os';
14
14
  import { join, delimiter } from 'node:path';
15
15
  export const ENDPOINT = 'https://sekura.ai/mcp';
@@ -17,7 +17,7 @@ export const SERVER_NAME = 'sekura';
17
17
  export const DOCS = 'https://sekura.ai/connect/';
18
18
  /** The home directory, overridable for tests. */
19
19
  export const home = () => process.env.SEKURA_HOME || homedir();
20
- /** VS Code's user directory, where Copilot's `mcp.json` and Cline's settings live. */
20
+ /** VS Code's user directory, where Copilot's `mcp.json` lives. */
21
21
  export function codeUserDir(h = home()) {
22
22
  switch (platform()) {
23
23
  case 'darwin': return join(h, 'Library', 'Application Support', 'Code', 'User');
@@ -26,6 +26,34 @@ export function codeUserDir(h = home()) {
26
26
  }
27
27
  }
28
28
  const xdg = (h) => process.env.XDG_CONFIG_HOME || join(h, '.config');
29
+ /** Cline's MCP settings. Since v4 the extension and the Cline CLI share one
30
+ * file under ~/.cline/data (cline/cline, sdk/packages/shared/src/storage/
31
+ * paths.ts, resolveMcpSettingsPath), with the same three overrides. The
32
+ * file in VS Code's global storage is only migrated from, once per source,
33
+ * so an entry written there after that is never read. */
34
+ export function clineSettingsPath(h = home()) {
35
+ const explicit = process.env.CLINE_MCP_SETTINGS_PATH?.trim();
36
+ if (explicit)
37
+ return explicit;
38
+ const data = process.env.CLINE_DATA_DIR?.trim() || join(process.env.CLINE_DIR?.trim() || join(h, '.cline'), 'data');
39
+ return join(data, 'settings', 'cline_mcp_settings.json');
40
+ }
41
+ /** True when a VS Code extension is installed: its folder is `<publisher.name>-<version>`. */
42
+ function hasExtension(h, id) {
43
+ try {
44
+ return readdirSync(join(h, '.vscode', 'extensions')).some((d) => d.toLowerCase().startsWith(`${id}-`));
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /** Goose's config directory: ~/.config/goose on macOS and Linux (Goose uses
51
+ * the XDG layout on both), %APPDATA%\Block\goose\config on Windows. */
52
+ export function gooseDir(h = home()) {
53
+ if (platform() === 'win32')
54
+ return join(process.env.APPDATA || join(h, 'AppData', 'Roaming'), 'Block', 'goose', 'config');
55
+ return join(xdg(h), 'goose');
56
+ }
29
57
  /** True when `bin` is an executable on PATH. */
30
58
  export function onPath(bin) {
31
59
  const exts = platform() === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
@@ -46,7 +74,7 @@ export const TOOLS = [
46
74
  id: 'tools-claude', name: 'Claude Code',
47
75
  detect: (h) => existsSync(join(h, '.claude')) || existsSync(join(h, '.claude.json')) || onPath('claude'),
48
76
  user: (h) => [
49
- { kind: 'command', bin: 'claude', add: ['mcp', 'add', '--transport', 'http', '-s', 'user', SERVER_NAME, ENDPOINT], remove: ['mcp', 'remove', '-s', 'user', SERVER_NAME] },
77
+ { kind: 'command', bin: 'claude', add: ['mcp', 'add', '--transport', 'http', '-s', 'user', SERVER_NAME, ENDPOINT], remove: ['mcp', 'remove', '-s', 'user', SERVER_NAME], get: ['mcp', 'get', SERVER_NAME] },
50
78
  { kind: 'json', path: join(h, '.claude.json'), key: 'mcpServers', entry: http },
51
79
  ],
52
80
  project: (cwd) => [{ kind: 'json', path: join(cwd, '.mcp.json'), key: 'mcpServers', entry: http }],
@@ -84,8 +112,10 @@ export const TOOLS = [
84
112
  },
85
113
  {
86
114
  id: 'tools-cline', name: 'Cline',
87
- detect: (h) => existsSync(join(codeUserDir(h), 'globalStorage', 'saoudrizwan.claude-dev')),
88
- user: (h) => [{ kind: 'json', path: join(codeUserDir(h), 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), key: 'mcpServers', entry: { type: 'streamableHttp', url: ENDPOINT } }],
115
+ // A fresh v4 install has no global-storage folder until the extension
116
+ // writes one, so the installed extension and ~/.cline count too.
117
+ detect: (h) => existsSync(join(h, '.cline')) || hasExtension(h, 'saoudrizwan.claude-dev') || existsSync(join(codeUserDir(h), 'globalStorage', 'saoudrizwan.claude-dev')),
118
+ user: (h) => [{ kind: 'json', path: clineSettingsPath(h), key: 'mcpServers', entry: { type: 'streamableHttp', url: ENDPOINT } }],
89
119
  signIn: SIGN_IN,
90
120
  },
91
121
  {
@@ -138,8 +168,13 @@ export const TOOLS = [
138
168
  },
139
169
  {
140
170
  id: 'tools-goose', name: 'Goose',
141
- detect: (h) => existsSync(join(xdg(h), 'goose')) || onPath('goose'),
142
- user: () => [{ kind: 'link', href: `${DOCS}#tools-goose`, note: 'Goose takes a goose://extension link or goose configure; the page has both.' }],
171
+ detect: (h) => existsSync(gooseDir(h)) || onPath('goose'),
172
+ // The streamable_http shape of Goose's ExtensionConfig (block/goose,
173
+ // crates/goose/src/agents/extension.rs; documentation/docs/guides/config-files.md).
174
+ user: (h) => [{ kind: 'yaml', path: join(gooseDir(h), 'config.yaml'), key: 'extensions', lines: [
175
+ 'type: streamable_http', `name: ${SERVER_NAME}`, 'enabled: true', `uri: ${ENDPOINT}`,
176
+ 'envs: {}', 'env_keys: []', 'headers: {}', 'timeout: 300', 'bundled: null', 'available_tools: []',
177
+ ] }],
143
178
  signIn: SIGN_IN,
144
179
  },
145
180
  ];
@@ -2,10 +2,25 @@
2
2
  // nothing is written before the review is shown and (unless --yes) agreed.
3
3
  import { Command } from 'commander';
4
4
  import { checkbox, confirm, select } from '@inquirer/prompts';
5
- import { DOCS, ENDPOINT, TOOLS } from '../catalogue.js';
5
+ import { realpathSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { DOCS, ENDPOINT, TOOLS, home } from '../catalogue.js';
6
9
  import { applyStep, buildPlan, describeStep, detectTools } from '../plan.js';
7
10
  import { verifyEndpoint } from '../verify.js';
8
11
  import { banner, cross, dot, say, tick } from '../ui.js';
12
+ /** The home folder (or the top of the disk) is no project: project scope
13
+ * there would put AGENTS.md and .mcp.json in it for every tool to find. */
14
+ export function isProjectDir(cwd) {
15
+ const real = (p) => { try {
16
+ return realpathSync(p);
17
+ }
18
+ catch {
19
+ return resolve(p);
20
+ } };
21
+ const dir = real(cwd);
22
+ return dir !== real(home()) && dir !== real(homedir()) && dirname(dir) !== dir;
23
+ }
9
24
  export async function runInit(opts) {
10
25
  const interactive = !opts.yes && process.stdin.isTTY && process.stderr.isTTY;
11
26
  const cwd = opts.cwd ?? process.cwd();
@@ -35,7 +50,13 @@ export async function runInit(opts) {
35
50
  }
36
51
  // 2. Scope and skill.
37
52
  let scope = opts.scope ?? 'user';
38
- if (!opts.scope && interactive) {
53
+ const inProject = isProjectDir(cwd);
54
+ if (scope === 'project' && !inProject) {
55
+ say(` ${cwd} is your home folder or the top of the disk, not a project: run sekura init --scope project inside the project, or use --scope user.`);
56
+ say(' Nothing was changed.');
57
+ return { written: 0, byHand: 0, failed: 1 };
58
+ }
59
+ if (!opts.scope && interactive && inProject) {
39
60
  scope = await select({
40
61
  message: 'Where',
41
62
  choices: [
package/dist/writers.js CHANGED
@@ -8,11 +8,13 @@
8
8
  // save them a copy-paste. Such a file is reported with the lines to add by
9
9
  // hand. A TOML file (Codex) is edited as text: the table is appended when
10
10
  // absent, replaced when present, and the rest of the file is left byte for
11
- // byte.
11
+ // byte. So is a YAML file (Goose): the one entry under its top-level key,
12
+ // at the indent the file already uses; Goose rewrites the file itself, so
13
+ // whether the entry is there is read from its fields, not its text.
12
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
15
  import { dirname } from 'node:path';
14
16
  import { spawnSync } from 'node:child_process';
15
- import { SERVER_NAME } from './catalogue.js';
17
+ import { ENDPOINT, SERVER_NAME } from './catalogue.js';
16
18
  const describeJson = (t) => JSON.stringify({ [t.key]: { [SERVER_NAME]: t.entry } }, null, 2);
17
19
  /** What applying the target would do, for the review before any write. */
18
20
  export function describe(t, dryRun = false) {
@@ -20,6 +22,7 @@ export function describe(t, dryRun = false) {
20
22
  switch (t.kind) {
21
23
  case 'json': return `${verb}write ${t.path}: ${t.key}.${SERVER_NAME} = ${JSON.stringify(t.entry)}`;
22
24
  case 'toml': return `${verb}write ${t.path}: [${t.table}] ${t.lines.join('; ')}`;
25
+ case 'yaml': return `${verb}write ${t.path}: ${t.key}.${SERVER_NAME} (${t.lines.slice(0, 4).join(', ')})`;
23
26
  case 'command': return `${verb}run ${t.bin} ${t.add.join(' ')}`;
24
27
  case 'link': return `by hand: ${t.note} ${t.href}`;
25
28
  }
@@ -123,9 +126,109 @@ export function hasToml(t) {
123
126
  const span = tomlSpan(text, t.table);
124
127
  return !!span && text.slice(span[0], span[1]).trim() === tomlBlock(t).trim();
125
128
  }
129
+ const yamlBlock = (t, indent) => `${indent}${SERVER_NAME}:\n` + t.lines.map((l) => `${indent}${indent}${l}\n`).join('');
130
+ const leading = (line) => /^[ ]*/.exec(line)[0];
131
+ const blank = (line) => !line.trim() || /^\s*#/.test(line);
132
+ /** Where `key:` and our entry under it sit, line by line. `inline` is a key
133
+ * whose value is on its own line (`extensions: {}`), which is not edited. */
134
+ function yamlLayout(lines, key) {
135
+ const head = lines.findIndex((l) => l.startsWith(`${key}:`));
136
+ if (head < 0)
137
+ return { missing: true };
138
+ if (!/^[^:]+:[ \t]*(#.*)?$/.test(lines[head]))
139
+ return { head, inline: true };
140
+ let end = head + 1;
141
+ while (end < lines.length && (blank(lines[end]) ? !/^#/.test(lines[end]) : /^\s/.test(lines[end])))
142
+ end++;
143
+ while (end > head + 1 && !lines[end - 1].trim())
144
+ end--;
145
+ const first = lines.slice(head + 1, end).find((l) => !blank(l));
146
+ const indent = first ? leading(first) : ' ';
147
+ const start = lines.findIndex((l, i) => i > head && i < end && l.replace(/\s+$/, '') === `${indent}${SERVER_NAME}:`);
148
+ let stop = start + 1;
149
+ if (start >= 0)
150
+ while (stop < end && (blank(lines[stop]) || leading(lines[stop]).length > indent.length))
151
+ stop++;
152
+ while (start >= 0 && stop > start + 1 && !lines[stop - 1].trim())
153
+ stop--;
154
+ return { head, end, indent, entry: start >= 0 ? [start, stop] : null };
155
+ }
156
+ const unquote = (v) => v.replace(/\s+#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2');
157
+ /** Our entry's own fields as they stand, whatever order or quoting Goose gave them. */
158
+ function yamlFields(lines) {
159
+ const own = lines.find((l) => !blank(l));
160
+ const depth = own ? leading(own).length : 0;
161
+ const out = {};
162
+ for (const l of lines) {
163
+ const m = /^( *)([A-Za-z_]+):(.*)$/.exec(l);
164
+ if (m && m[1].length === depth)
165
+ out[m[2]] = unquote(m[3]);
166
+ }
167
+ return out;
168
+ }
169
+ function yamlOk(t, entry) {
170
+ const f = yamlFields(entry);
171
+ const want = yamlFields(t.lines);
172
+ return f.type === want.type && f.uri === ENDPOINT && f.enabled === 'true';
173
+ }
174
+ const splitLines = (text) => text.replace(/\n$/, '').split('\n');
175
+ export function applyYaml(t) {
176
+ const text = existsSync(t.path) ? readFileSync(t.path, 'utf8') : '';
177
+ if (/\t/.test(text.split('\n').filter((l) => /^\s/.test(l)).join(''))) {
178
+ return { status: 'by-hand', where: t.path, why: 'it is indented with tabs, which this installer will not rewrite', snippet: `${t.key}:\n${yamlBlock(t, ' ')}` };
179
+ }
180
+ const lines = text ? splitLines(text) : [];
181
+ const at = yamlLayout(lines, t.key);
182
+ if ('inline' in at) {
183
+ return { status: 'by-hand', where: t.path, why: `its ${t.key} is written on one line, which this installer will not rewrite`, snippet: `${t.key}:\n${yamlBlock(t, ' ')}` };
184
+ }
185
+ let out;
186
+ if ('missing' in at) {
187
+ const sep = !text ? [] : lines[lines.length - 1].trim() ? [''] : [];
188
+ out = [...lines, ...sep, `${t.key}:`, ...splitLines(yamlBlock(t, ' '))];
189
+ }
190
+ else if (at.entry) {
191
+ const [a, b] = at.entry;
192
+ if (yamlOk(t, lines.slice(a + 1, b)))
193
+ return { status: 'unchanged', where: t.path };
194
+ out = [...lines.slice(0, a), ...splitLines(yamlBlock(t, at.indent)), ...lines.slice(b)];
195
+ }
196
+ else {
197
+ out = [...lines.slice(0, at.end), ...splitLines(yamlBlock(t, at.indent)), ...lines.slice(at.end)];
198
+ }
199
+ mkdirSync(dirname(t.path), { recursive: true });
200
+ writeFileSync(t.path, out.join('\n') + '\n');
201
+ return { status: 'written', where: t.path };
202
+ }
203
+ export function removeYaml(t) {
204
+ if (!existsSync(t.path))
205
+ return { status: 'absent', where: t.path };
206
+ const lines = splitLines(readFileSync(t.path, 'utf8'));
207
+ const at = yamlLayout(lines, t.key);
208
+ if (!('entry' in at) || !at.entry)
209
+ return { status: 'absent', where: t.path };
210
+ const [a, b] = at.entry;
211
+ const out = [...lines.slice(0, a), ...lines.slice(b)];
212
+ // A mapping left with nothing under it reads as null, which Goose refuses.
213
+ const rest = [...lines.slice(at.head + 1, a), ...lines.slice(b, at.end)];
214
+ if (!rest.some((l) => !blank(l)))
215
+ out[at.head] = `${t.key}: {}`;
216
+ writeFileSync(t.path, out.join('\n') + '\n');
217
+ return { status: 'removed', where: t.path };
218
+ }
219
+ export function hasYaml(t) {
220
+ if (!existsSync(t.path))
221
+ return false;
222
+ const lines = splitLines(readFileSync(t.path, 'utf8'));
223
+ const at = yamlLayout(lines, t.key);
224
+ return 'entry' in at && !!at.entry && yamlOk(t, lines.slice(at.entry[0] + 1, at.entry[1]));
225
+ }
126
226
  // ── commands ────────────────────────────────────────────────────────────────
227
+ function spawn(bin, args) {
228
+ return spawnSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
229
+ }
127
230
  export function runCommand(bin, args) {
128
- const r = spawnSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
231
+ const r = spawn(bin, args);
129
232
  const where = `${bin} ${args.join(' ')}`;
130
233
  if (r.error)
131
234
  return { status: 'failed', where, why: r.error.message };
@@ -133,12 +236,34 @@ export function runCommand(bin, args) {
133
236
  return { status: 'failed', where, why: (r.stderr || r.stdout || `exit ${r.status}`).trim().split('\n').slice(-3).join(' ') };
134
237
  return { status: 'ran', where };
135
238
  }
136
- // ── one door for the three kinds ────────────────────────────────────────────
239
+ /** Whether the tool's own command lists our entry at our address. */
240
+ function commandHas(t) {
241
+ if (!t.get)
242
+ return 'unknown';
243
+ const r = spawn(t.bin, t.get);
244
+ if (r.error)
245
+ return 'unknown';
246
+ return r.status === 0 && `${r.stdout}`.includes(ENDPOINT);
247
+ }
248
+ /** Adds the entry. A tool that refuses because the name is taken is asked
249
+ * what it holds: our address is "already there"; another one is replaced,
250
+ * the way a JSON entry pointing elsewhere is corrected on rerun. */
251
+ export function applyCommand(t) {
252
+ const r = runCommand(t.bin, t.add);
253
+ if (r.status !== 'failed' || !t.get || !/already exists/i.test(r.why))
254
+ return r;
255
+ if (commandHas(t) === true)
256
+ return { status: 'unchanged', where: `${t.bin} ${t.get.join(' ')}` };
257
+ const out = runCommand(t.bin, t.remove);
258
+ return out.status === 'failed' ? out : runCommand(t.bin, t.add);
259
+ }
260
+ // ── one door for every kind ────────────────────────────────────────────
137
261
  export function apply(t) {
138
262
  switch (t.kind) {
139
263
  case 'json': return applyJson(t);
140
264
  case 'toml': return applyToml(t);
141
- case 'command': return runCommand(t.bin, t.add);
265
+ case 'yaml': return applyYaml(t);
266
+ case 'command': return applyCommand(t);
142
267
  case 'link': return { status: 'by-hand', where: t.href, why: t.note, snippet: '' };
143
268
  }
144
269
  }
@@ -146,6 +271,7 @@ export function remove(t) {
146
271
  switch (t.kind) {
147
272
  case 'json': return removeJson(t);
148
273
  case 'toml': return removeToml(t);
274
+ case 'yaml': return removeYaml(t);
149
275
  case 'command': return runCommand(t.bin, t.remove);
150
276
  case 'link': return { status: 'absent', where: t.href };
151
277
  }
@@ -158,7 +284,8 @@ export function has(t) {
158
284
  return r === 'unreadable' ? 'unknown' : r;
159
285
  }
160
286
  case 'toml': return hasToml(t);
161
- case 'command': return 'unknown';
287
+ case 'yaml': return hasYaml(t);
288
+ case 'command': return commandHas(t);
162
289
  case 'link': return 'unknown';
163
290
  }
164
291
  }
package/package.json CHANGED
@@ -1,17 +1,40 @@
1
1
  {
2
2
  "name": "sekura",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Connect your coding tool to Sekura in one command: npx sekura@latest init",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://sekura.ai/connect/",
8
- "repository": { "type": "git", "url": "git+https://github.com/sekuraai/sekura-cli.git" },
9
- "bugs": { "url": "https://github.com/sekuraai/sekura-cli/issues" },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sekuraai/sekura-cli.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/sekuraai/sekura-cli/issues"
14
+ },
10
15
  "author": "Sekura Inc. <hello@sekura.ai>",
11
- "keywords": ["sekura", "mcp", "security", "vulnerability-scanning", "claude-code", "cursor", "copilot", "codex"],
12
- "bin": { "sekura": "dist/cli.js" },
13
- "files": ["dist", "skills", "README.md", "LICENSE"],
14
- "engines": { "node": ">=18" },
16
+ "keywords": [
17
+ "sekura",
18
+ "mcp",
19
+ "security",
20
+ "vulnerability-scanning",
21
+ "claude-code",
22
+ "cursor",
23
+ "copilot",
24
+ "codex"
25
+ ],
26
+ "bin": {
27
+ "sekura": "dist/cli.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "skills",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
15
38
  "scripts": {
16
39
  "build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
17
40
  "test": "npm run build && node --test test/init.test.mjs",