sekura 0.1.0 → 0.2.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/README.md +26 -12
- package/dist/api.js +42 -0
- package/dist/auth.js +172 -0
- package/dist/catalogue.js +56 -15
- package/dist/cli.js +9 -5
- package/dist/commands/init.js +68 -14
- package/dist/commands/login.js +73 -0
- package/dist/commands/status.js +27 -6
- package/dist/commands/uninstall.js +6 -0
- package/dist/guide.js +215 -0
- package/dist/plan.js +3 -1
- package/dist/repo.js +14 -0
- package/dist/ui.js +1 -0
- package/dist/writers.js +151 -9
- package/package.json +31 -8
- package/skills/sekura/SKILL.md +53 -1
package/README.md
CHANGED
|
@@ -6,25 +6,36 @@ Connect your coding tool to [Sekura](https://sekura.ai) in one command.
|
|
|
6
6
|
npx sekura@latest init
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Sekura is an AI red team as a service: it scans a GitHub repository you have granted it, exploits what it finds to prove each finding, and delivers a report. Its MCP server is remote, at `https://sekura.ai/mcp`, so this package runs no server
|
|
9
|
+
Sekura is an AI red team as a service: it scans a GitHub repository you have granted it, exploits what it finds to prove each finding, and delivers a report. Its MCP server is remote, at `https://sekura.ai/mcp`, so this package runs no server. It writes that address into the coding tools on your machine and installs a skill that teaches the agent the quote-then-confirm rule. Then it finishes getting started right in the terminal: it signs you in once, helps you install the GitHub App, and lets you pick a repository. When you open your coding tool, Sekura already works.
|
|
10
10
|
|
|
11
11
|
## What `init` does
|
|
12
12
|
|
|
13
13
|
1. **Detects** the tools on this machine: Claude Code, Cursor, GitHub Copilot in VS Code, OpenAI Codex, Kiro, Cline, Zed, Antigravity, OpenCode, JetBrains Junie, Factory Droid, Tabnine, Devin and Goose.
|
|
14
14
|
2. **Asks** which to connect, and whether for every project (your user configuration) or this project only.
|
|
15
|
-
3. **
|
|
16
|
-
4. **
|
|
17
|
-
5. **
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
3. **Signs you in** once, in your browser. You sign in with GitHub on sekura.ai and approve, and the CLI receives a Sekura key and gives it to each tool, so none of them asks you to sign in. Codex and Devin can't take a key in their configuration, so the CLI runs their own `mcp login` for you; since your browser is already signed in, that's one click.
|
|
16
|
+
4. **Shows the plan** before anything is written: each file and the exact entry (with the key shown as `sek_live_…abcd`), or the tool's own command.
|
|
17
|
+
5. **Applies** it, one step at a time, and says what each did. Files that are not plain JSON (Zed's `settings.json` with comments, for one) are never rewritten; you are shown the lines to add instead.
|
|
18
|
+
6. **Checks** that the Sekura server answers.
|
|
19
|
+
7. **Gets you to a first scan**:
|
|
20
|
+
- It says what your account still needs (the Terms, or email verification).
|
|
21
|
+
- It offers to install the Sekura GitHub App when nothing can be scanned yet, or when the repository in the current folder isn't covered.
|
|
22
|
+
- It lets you pick a repository, with the current folder's first.
|
|
23
|
+
- If your free first scan is still available, it opens sekura.ai so you can start it there.
|
|
24
|
+
8. **Tells you what to type** in each tool, such as `/sekura scan owner/repo` in Claude Code (`/sekura help` lists every command).
|
|
25
|
+
|
|
26
|
+
Skip the sign-in with `--no-login`; each tool then opens a sekura.ai sign-in page the first time it uses Sekura. Project scope never writes the key, because project files get committed.
|
|
20
27
|
|
|
21
28
|
```bash
|
|
22
29
|
npx sekura@latest init --yes # every detected tool, user scope, with the skill, no questions
|
|
23
30
|
npx sekura@latest init --dry-run # show the plan and change nothing
|
|
24
31
|
npx sekura@latest init --scope project # this project only: .mcp.json, .cursor/mcp.json, AGENTS.md …
|
|
25
32
|
npx sekura@latest init --tools claude,cursor --no-skill
|
|
26
|
-
npx sekura@latest
|
|
27
|
-
npx sekura@latest
|
|
33
|
+
npx sekura@latest init --no-login # no sign-in here; each tool signs in on first use
|
|
34
|
+
npx sekura@latest login # sign in again, give connected tools the key, pick a repository
|
|
35
|
+
npx sekura@latest status # what is connected, and who is signed in
|
|
36
|
+
npx sekura@latest logout # forget the saved key
|
|
37
|
+
npx sekura@latest uninstall # remove the entries, the skill, the guidance block and the saved key
|
|
38
|
+
SEKURA_API_KEY=sek_live_… npx sekura@latest init --yes # unattended, with a key from Settings
|
|
28
39
|
```
|
|
29
40
|
|
|
30
41
|
## What it writes
|
|
@@ -36,21 +47,24 @@ npx sekura@latest uninstall # remove the entries, the skill and the
|
|
|
36
47
|
| GitHub Copilot (VS Code) | the VS Code user `mcp.json` → `servers.sekura` | `.mcp.json` |
|
|
37
48
|
| OpenAI Codex | `codex mcp add sekura --url …`, or `~/.codex/config.toml` → `[mcp_servers.sekura]` | — |
|
|
38
49
|
| Kiro | `~/.kiro/settings/mcp.json` | `.kiro/settings/mcp.json` |
|
|
39
|
-
| Cline |
|
|
50
|
+
| Cline | `~/.cline/data/settings/cline_mcp_settings.json` (shared by the extension, v4 and later, and the Cline CLI) | — |
|
|
40
51
|
| Zed | `~/.config/zed/settings.json` → `context_servers.sekura` (shown by hand when the file has comments) | — |
|
|
41
52
|
| Antigravity | `~/.gemini/config/mcp_config.json` → `serverUrl` | `.agents/mcp_config.json` |
|
|
42
53
|
| OpenCode | `~/.config/opencode/opencode.json` → `mcp.sekura` | `opencode.json` |
|
|
43
54
|
| JetBrains Junie | — | `.junie/mcp/mcp.json` |
|
|
44
55
|
| Factory Droid, Tabnine, Devin | the tool's own `mcp add` command | — |
|
|
45
|
-
| Goose |
|
|
56
|
+
| Goose | `~/.config/goose/config.yaml` → `extensions.sekura` (`streamable_http`) | — |
|
|
46
57
|
|
|
47
58
|
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
59
|
|
|
49
60
|
## What stays untouched
|
|
50
61
|
|
|
51
62
|
- Every other server, setting and file. The installer sets one key, `sekura`, and reads nothing else.
|
|
52
|
-
- Your source code
|
|
53
|
-
|
|
63
|
+
- Your source code isn't read or uploaded by this installer.
|
|
64
|
+
|
|
65
|
+
## Your key
|
|
66
|
+
|
|
67
|
+
Signing in creates one Sekura key, named "Sekura CLI (your computer's name)". You can see it and revoke it at [sekura.ai/app/settings](https://sekura.ai/app/settings) → Agents and API keys. The CLI saves it in `~/.config/sekura/credentials.json`, readable by you only, and writes it as an `Authorization: Bearer` header into each tool's user configuration. `sekura uninstall` removes both, and `sekura logout` removes the saved copy. Revoking the key in Settings ends it everywhere; run `sekura login` to get a new one.
|
|
54
68
|
|
|
55
69
|
Review before running: the source is this repository, the package is [`sekura` on npm](https://www.npmjs.com/package/sekura), and `--dry-run` prints every write the wizard would make.
|
|
56
70
|
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The few Sekura tools the CLI calls itself, over the same MCP endpoint the
|
|
2
|
+
// coding tools use. The server is stateless JSON-RPC over POST and answers
|
|
3
|
+
// in plain JSON, so no session and no MCP library: one request per call.
|
|
4
|
+
// Only tools that read are called here; every scan is still quoted and
|
|
5
|
+
// agreed in the coding tool, or started by the person on sekura.ai.
|
|
6
|
+
import { ENDPOINT } from './catalogue.js';
|
|
7
|
+
export class SignedOut extends Error {
|
|
8
|
+
constructor(detail) { super(detail); this.name = 'SignedOut'; }
|
|
9
|
+
}
|
|
10
|
+
let nextId = 1;
|
|
11
|
+
export async function callTool(key, name, args = {}) {
|
|
12
|
+
const r = await fetch(ENDPOINT, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
|
15
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: nextId++, method: 'tools/call', params: { name, arguments: args } }),
|
|
16
|
+
signal: AbortSignal.timeout(30_000),
|
|
17
|
+
});
|
|
18
|
+
const body = (await r.json().catch(() => null));
|
|
19
|
+
if (r.status === 401)
|
|
20
|
+
throw new SignedOut(body?.error_description || 'the key is no longer valid');
|
|
21
|
+
if (!r.ok || !body)
|
|
22
|
+
throw new Error(`Sekura answered ${r.status} to ${name}${body?.error_description ? `: ${body.error_description}` : ''}`);
|
|
23
|
+
if (body.error)
|
|
24
|
+
throw new Error(`Sekura ${name}: ${body.error.message ?? JSON.stringify(body.error)}`);
|
|
25
|
+
const result = body.result ?? {};
|
|
26
|
+
if (result.isError)
|
|
27
|
+
throw new Error(`Sekura ${name}: ${result.content?.[0]?.text ?? 'failed'}`);
|
|
28
|
+
if (result.structuredContent)
|
|
29
|
+
return result.structuredContent;
|
|
30
|
+
// Older answers carry the data as JSON in the second text block.
|
|
31
|
+
for (const c of result.content ?? []) {
|
|
32
|
+
if (c?.type !== 'text')
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(c.text);
|
|
36
|
+
}
|
|
37
|
+
catch { /* the prose block */ }
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`Sekura ${name}: no data in the answer`);
|
|
40
|
+
}
|
|
41
|
+
export const getAccount = (key) => callTool(key, 'getAccount');
|
|
42
|
+
export const listRepositories = (key) => callTool(key, 'listRepositories');
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Signing in to Sekura from the terminal, once, so the tools do not have to.
|
|
2
|
+
//
|
|
3
|
+
// OAuth 2.1 against sekura.ai, the way the tools themselves do it: a client
|
|
4
|
+
// registered on the spot (public, no secret), PKCE S256, and a loopback
|
|
5
|
+
// redirect on a port picked per run (RFC 8252; sekura.ai accepts any port
|
|
6
|
+
// on 127.0.0.1 for a client that registered a loopback URI). What comes back
|
|
7
|
+
// is an ordinary `sek_live_` key with no expiry, listed and revocable under
|
|
8
|
+
// Settings → Agents and API keys, bound to the MCP address (RFC 8707).
|
|
9
|
+
//
|
|
10
|
+
// The key is kept in ~/.config/sekura/credentials.json, readable by the
|
|
11
|
+
// person only, so a rerun of `init` or `login` does not sign in again.
|
|
12
|
+
// SEKURA_API_KEY in the environment wins over the file.
|
|
13
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
|
16
|
+
import { hostname, platform } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { ENDPOINT, home } from './catalogue.js';
|
|
20
|
+
export const ISSUER = new URL(ENDPOINT).origin;
|
|
21
|
+
export const credentialsPath = () => join(process.env.XDG_CONFIG_HOME || join(home(), '.config'), 'sekura', 'credentials.json');
|
|
22
|
+
export function readKey() {
|
|
23
|
+
const env = process.env.SEKURA_API_KEY?.trim();
|
|
24
|
+
if (env)
|
|
25
|
+
return env;
|
|
26
|
+
try {
|
|
27
|
+
const c = JSON.parse(readFileSync(credentialsPath(), 'utf8'));
|
|
28
|
+
return typeof c.key === 'string' && c.key ? c.key : null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function saveKey(key, email) {
|
|
35
|
+
const p = credentialsPath();
|
|
36
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
37
|
+
writeFileSync(p, JSON.stringify({ key, ...(email ? { email } : {}), createdAt: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 });
|
|
38
|
+
try {
|
|
39
|
+
chmodSync(p, 0o600);
|
|
40
|
+
}
|
|
41
|
+
catch { /* Windows */ }
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
export function forgetKey() {
|
|
45
|
+
const p = credentialsPath();
|
|
46
|
+
if (!existsSync(p))
|
|
47
|
+
return false;
|
|
48
|
+
rmSync(p);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
/** A key as it may be shown: the prefix and the last four characters. */
|
|
52
|
+
export function redact(key) {
|
|
53
|
+
const m = /^(sek_[a-z]+_)/.exec(key);
|
|
54
|
+
return `${m ? m[1] : ''}…${key.slice(-4)}`;
|
|
55
|
+
}
|
|
56
|
+
/** Every key in a piece of text, redacted. */
|
|
57
|
+
export const redactAll = (s) => s.replace(/sek_[a-z]+_[A-Za-z0-9]+/g, (k) => redact(k));
|
|
58
|
+
// ── PKCE ───────────────────────────────────────────────────────────────────
|
|
59
|
+
const b64url = (b) => b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
60
|
+
export function pkce() {
|
|
61
|
+
const verifier = b64url(randomBytes(32));
|
|
62
|
+
return { verifier, challenge: b64url(createHash('sha256').update(verifier).digest()) };
|
|
63
|
+
}
|
|
64
|
+
// ── the browser ────────────────────────────────────────────────────────────
|
|
65
|
+
/** Opens a URL in the default browser; false when there is none to ask. */
|
|
66
|
+
export function openBrowser(url) {
|
|
67
|
+
const [cmd, args] = platform() === 'darwin' ? ['open', [url]]
|
|
68
|
+
: platform() === 'win32' ? ['cmd', ['/c', 'start', '""', url.replace(/&/g, '^&')]]
|
|
69
|
+
: ['xdg-open', [url]];
|
|
70
|
+
try {
|
|
71
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
72
|
+
child.on('error', () => { });
|
|
73
|
+
child.unref();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ── the loopback callback ──────────────────────────────────────────────────
|
|
81
|
+
const PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;max-width:32em;margin:4em auto;padding:0 1em"><h1 style="font-size:1.4em">${title}</h1><p>${body}</p>`;
|
|
82
|
+
/** What one request to the callback means, given the state this run sent. */
|
|
83
|
+
export function readCallback(url, state) {
|
|
84
|
+
if (url.pathname !== '/callback')
|
|
85
|
+
return null;
|
|
86
|
+
if (url.searchParams.get('state') !== state)
|
|
87
|
+
return { error: 'the sign-in came back with another state; start again' };
|
|
88
|
+
const iss = url.searchParams.get('iss');
|
|
89
|
+
if (iss !== null && iss !== ISSUER)
|
|
90
|
+
return { error: `the sign-in came back from ${iss}, not ${ISSUER}` };
|
|
91
|
+
const err = url.searchParams.get('error');
|
|
92
|
+
if (err)
|
|
93
|
+
return { error: url.searchParams.get('error_description') || err };
|
|
94
|
+
const code = url.searchParams.get('code');
|
|
95
|
+
return code ? { code } : { error: 'the sign-in came back without a code' };
|
|
96
|
+
}
|
|
97
|
+
/** Listens on 127.0.0.1, on a port the system picks, for one callback. */
|
|
98
|
+
export async function listen(state, timeoutMs) {
|
|
99
|
+
let settle;
|
|
100
|
+
const result = new Promise((r) => { settle = r; });
|
|
101
|
+
const server = createServer((req, res) => {
|
|
102
|
+
const r = readCallback(new URL(req.url ?? '/', 'http://127.0.0.1'), state);
|
|
103
|
+
if (!r) {
|
|
104
|
+
res.writeHead(404).end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
108
|
+
res.end('code' in r
|
|
109
|
+
? PAGE('Signed in to Sekura', 'You can close this tab and go back to the terminal.')
|
|
110
|
+
: PAGE('Sekura could not sign you in', r.error.replace(/</g, '<')));
|
|
111
|
+
settle(r);
|
|
112
|
+
});
|
|
113
|
+
await new Promise((ok, fail) => { server.once('error', fail); server.listen(0, '127.0.0.1', () => ok()); });
|
|
114
|
+
const port = server.address().port;
|
|
115
|
+
const timer = setTimeout(() => settle({ error: 'no answer from the browser in time' }), timeoutMs);
|
|
116
|
+
timer.unref();
|
|
117
|
+
const close = () => { clearTimeout(timer); server.close(); server.closeAllConnections?.(); };
|
|
118
|
+
return { redirectUri: `http://127.0.0.1:${port}/callback`, result, close };
|
|
119
|
+
}
|
|
120
|
+
// ── the flow ───────────────────────────────────────────────────────────────
|
|
121
|
+
async function post(path, body, type) {
|
|
122
|
+
const r = await fetch(`${ISSUER}${path}`, { method: 'POST', headers: { 'content-type': type, accept: 'application/json' }, body });
|
|
123
|
+
const data = (await r.json().catch(() => ({})));
|
|
124
|
+
if (!r.ok)
|
|
125
|
+
throw new Error(`sekura.ai refused the sign-in (${path}): ${data.error_description || data.error || r.status}`);
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Signs in in the browser and returns the key. `onUrl` is told the address
|
|
130
|
+
* before the browser is asked to open it, so it can be printed for a machine
|
|
131
|
+
* with no browser to open.
|
|
132
|
+
*/
|
|
133
|
+
export async function signIn(opts = {}) {
|
|
134
|
+
const state = b64url(randomBytes(16));
|
|
135
|
+
const { verifier, challenge } = pkce();
|
|
136
|
+
const cb = await listen(state, opts.timeoutMs ?? 300_000);
|
|
137
|
+
try {
|
|
138
|
+
const reg = await post('/oauth/register', JSON.stringify({
|
|
139
|
+
client_name: `Sekura CLI (${hostname().slice(0, 60)})`,
|
|
140
|
+
redirect_uris: ['http://127.0.0.1/callback'],
|
|
141
|
+
token_endpoint_auth_method: 'none',
|
|
142
|
+
grant_types: ['authorization_code'],
|
|
143
|
+
response_types: ['code'],
|
|
144
|
+
}), 'application/json');
|
|
145
|
+
const clientId = String(reg.client_id ?? '');
|
|
146
|
+
if (!clientId)
|
|
147
|
+
throw new Error('sekura.ai did not register the CLI');
|
|
148
|
+
const url = new URL(`${ISSUER}/oauth/authorize`);
|
|
149
|
+
for (const [k, v] of Object.entries({
|
|
150
|
+
response_type: 'code', client_id: clientId, redirect_uri: cb.redirectUri, state,
|
|
151
|
+
code_challenge: challenge, code_challenge_method: 'S256', scope: 'read write', resource: ENDPOINT,
|
|
152
|
+
}))
|
|
153
|
+
url.searchParams.set(k, v);
|
|
154
|
+
opts.onUrl?.(url.href);
|
|
155
|
+
if (opts.open !== false)
|
|
156
|
+
openBrowser(url.href);
|
|
157
|
+
const r = await cb.result;
|
|
158
|
+
if ('error' in r)
|
|
159
|
+
throw new Error(`Sekura sign-in: ${r.error}`);
|
|
160
|
+
const tok = await post('/oauth/token', new URLSearchParams({
|
|
161
|
+
grant_type: 'authorization_code', code: r.code, code_verifier: verifier,
|
|
162
|
+
client_id: clientId, redirect_uri: cb.redirectUri, resource: ENDPOINT,
|
|
163
|
+
}).toString(), 'application/x-www-form-urlencoded');
|
|
164
|
+
const key = String(tok.access_token ?? '');
|
|
165
|
+
if (!key)
|
|
166
|
+
throw new Error('sekura.ai answered without a key');
|
|
167
|
+
return key;
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
cb.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
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`
|
|
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', ''] : [''];
|
|
@@ -41,13 +69,17 @@ export function onPath(bin) {
|
|
|
41
69
|
const SIGN_IN = 'the first time the tool uses Sekura, a sekura.ai page opens in your browser: sign in with GitHub and approve.';
|
|
42
70
|
const url = { url: ENDPOINT };
|
|
43
71
|
const http = { type: 'http', url: ENDPOINT };
|
|
72
|
+
/** The header a key is sent in, as JSON configurations spell it. */
|
|
73
|
+
const auth = (key) => (key ? { headers: { Authorization: `Bearer ${key}` } } : {});
|
|
74
|
+
/** True when a target sends a key, so its tool needs no sign-in. */
|
|
75
|
+
export const carriesKey = (t) => JSON.stringify(t).includes('Bearer sek_');
|
|
44
76
|
export const TOOLS = [
|
|
45
77
|
{
|
|
46
78
|
id: 'tools-claude', name: 'Claude Code',
|
|
47
79
|
detect: (h) => existsSync(join(h, '.claude')) || existsSync(join(h, '.claude.json')) || onPath('claude'),
|
|
48
|
-
user: (h) => [
|
|
49
|
-
{ kind: 'command', bin: 'claude', add: ['mcp', 'add', '--transport', 'http', '-s', 'user', SERVER_NAME, ENDPOINT], remove: ['mcp', 'remove', '-s', 'user', SERVER_NAME] },
|
|
50
|
-
{ kind: 'json', path: join(h, '.claude.json'), key: 'mcpServers', entry: http },
|
|
80
|
+
user: (h, key) => [
|
|
81
|
+
{ kind: 'command', bin: 'claude', add: ['mcp', 'add', '--transport', 'http', '-s', 'user', SERVER_NAME, ENDPOINT, ...(key ? ['--header', `Authorization: Bearer ${key}`] : [])], remove: ['mcp', 'remove', '-s', 'user', SERVER_NAME], get: ['mcp', 'get', SERVER_NAME] },
|
|
82
|
+
{ kind: 'json', path: join(h, '.claude.json'), key: 'mcpServers', entry: { ...http, ...auth(key) } },
|
|
51
83
|
],
|
|
52
84
|
project: (cwd) => [{ kind: 'json', path: join(cwd, '.mcp.json'), key: 'mcpServers', entry: http }],
|
|
53
85
|
signIn: `type /mcp in Claude Code, choose ${SERVER_NAME}, and sign in with GitHub.`,
|
|
@@ -55,14 +87,14 @@ export const TOOLS = [
|
|
|
55
87
|
{
|
|
56
88
|
id: 'tools-cursor', name: 'Cursor',
|
|
57
89
|
detect: (h) => existsSync(join(h, '.cursor')) || onPath('cursor'),
|
|
58
|
-
user: (h) => [{ kind: 'json', path: join(h, '.cursor', 'mcp.json'), key: 'mcpServers', entry: url }],
|
|
90
|
+
user: (h, key) => [{ kind: 'json', path: join(h, '.cursor', 'mcp.json'), key: 'mcpServers', entry: { ...url, ...auth(key) } }],
|
|
59
91
|
project: (cwd) => [{ kind: 'json', path: join(cwd, '.cursor', 'mcp.json'), key: 'mcpServers', entry: url }],
|
|
60
92
|
signIn: SIGN_IN,
|
|
61
93
|
},
|
|
62
94
|
{
|
|
63
95
|
id: 'tools-copilot', name: 'GitHub Copilot in VS Code',
|
|
64
96
|
detect: (h) => existsSync(codeUserDir(h)),
|
|
65
|
-
user: (h) => [{ kind: 'json', path: join(codeUserDir(h), 'mcp.json'), key: 'servers', entry: http }],
|
|
97
|
+
user: (h, key) => [{ kind: 'json', path: join(codeUserDir(h), 'mcp.json'), key: 'servers', entry: { ...http, ...auth(key) } }],
|
|
66
98
|
project: (cwd) => [{ kind: 'json', path: join(cwd, '.mcp.json'), key: 'mcpServers', entry: http }],
|
|
67
99
|
signIn: SIGN_IN,
|
|
68
100
|
},
|
|
@@ -74,37 +106,40 @@ export const TOOLS = [
|
|
|
74
106
|
{ kind: 'toml', path: join(h, '.codex', 'config.toml'), table: `mcp_servers.${SERVER_NAME}`, lines: [`url = "${ENDPOINT}"`] },
|
|
75
107
|
],
|
|
76
108
|
signIn: `run codex mcp login ${SERVER_NAME}, or sign in when Codex first uses Sekura.`,
|
|
109
|
+
login: ['mcp', 'login', SERVER_NAME],
|
|
77
110
|
},
|
|
78
111
|
{
|
|
79
112
|
id: 'tools-kiro', name: 'Kiro',
|
|
80
113
|
detect: (h) => existsSync(join(h, '.kiro')) || onPath('kiro'),
|
|
81
|
-
user: (h) => [{ kind: 'json', path: join(h, '.kiro', 'settings', 'mcp.json'), key: 'mcpServers', entry: url }],
|
|
114
|
+
user: (h, key) => [{ kind: 'json', path: join(h, '.kiro', 'settings', 'mcp.json'), key: 'mcpServers', entry: { ...url, ...auth(key) } }],
|
|
82
115
|
project: (cwd) => [{ kind: 'json', path: join(cwd, '.kiro', 'settings', 'mcp.json'), key: 'mcpServers', entry: url }],
|
|
83
116
|
signIn: SIGN_IN,
|
|
84
117
|
},
|
|
85
118
|
{
|
|
86
119
|
id: 'tools-cline', name: 'Cline',
|
|
87
|
-
|
|
88
|
-
|
|
120
|
+
// A fresh v4 install has no global-storage folder until the extension
|
|
121
|
+
// writes one, so the installed extension and ~/.cline count too.
|
|
122
|
+
detect: (h) => existsSync(join(h, '.cline')) || hasExtension(h, 'saoudrizwan.claude-dev') || existsSync(join(codeUserDir(h), 'globalStorage', 'saoudrizwan.claude-dev')),
|
|
123
|
+
user: (h, key) => [{ kind: 'json', path: clineSettingsPath(h), key: 'mcpServers', entry: { type: 'streamableHttp', url: ENDPOINT, ...auth(key) } }],
|
|
89
124
|
signIn: SIGN_IN,
|
|
90
125
|
},
|
|
91
126
|
{
|
|
92
127
|
id: 'tools-zed', name: 'Zed',
|
|
93
128
|
detect: (h) => existsSync(join(xdg(h), 'zed')) || onPath('zed'),
|
|
94
|
-
user: (h) => [{ kind: 'json', path: join(xdg(h), 'zed', 'settings.json'), key: 'context_servers', entry: url }],
|
|
129
|
+
user: (h, key) => [{ kind: 'json', path: join(xdg(h), 'zed', 'settings.json'), key: 'context_servers', entry: { ...url, ...auth(key) } }],
|
|
95
130
|
signIn: `${SIGN_IN} Zed keeps the sign-in in your system keychain.`,
|
|
96
131
|
},
|
|
97
132
|
{
|
|
98
133
|
id: 'tools-antigravity', name: 'Antigravity',
|
|
99
134
|
detect: (h) => existsSync(join(h, '.gemini', 'config')),
|
|
100
|
-
user: (h) => [{ kind: 'json', path: join(h, '.gemini', 'config', 'mcp_config.json'), key: 'mcpServers', entry: { serverUrl: ENDPOINT } }],
|
|
135
|
+
user: (h, key) => [{ kind: 'json', path: join(h, '.gemini', 'config', 'mcp_config.json'), key: 'mcpServers', entry: { serverUrl: ENDPOINT, ...auth(key) } }],
|
|
101
136
|
project: (cwd) => [{ kind: 'json', path: join(cwd, '.agents', 'mcp_config.json'), key: 'mcpServers', entry: { serverUrl: ENDPOINT } }],
|
|
102
137
|
signIn: SIGN_IN,
|
|
103
138
|
},
|
|
104
139
|
{
|
|
105
140
|
id: 'tools-opencode', name: 'OpenCode',
|
|
106
141
|
detect: (h) => existsSync(join(xdg(h), 'opencode')) || onPath('opencode'),
|
|
107
|
-
user: (h) => [{ kind: 'json', path: join(xdg(h), 'opencode', 'opencode.json'), key: 'mcp', entry: { type: 'remote', url: ENDPOINT } }],
|
|
142
|
+
user: (h, key) => [{ kind: 'json', path: join(xdg(h), 'opencode', 'opencode.json'), key: 'mcp', entry: { type: 'remote', url: ENDPOINT, ...auth(key) } }],
|
|
108
143
|
project: (cwd) => [{ kind: 'json', path: join(cwd, 'opencode.json'), key: 'mcp', entry: { type: 'remote', url: ENDPOINT } }],
|
|
109
144
|
signIn: `OpenCode registers itself with Sekura and opens the sign-in on first use; ${SIGN_IN}`,
|
|
110
145
|
},
|
|
@@ -135,11 +170,17 @@ export const TOOLS = [
|
|
|
135
170
|
detect: () => onPath('devin'),
|
|
136
171
|
user: () => [{ kind: 'command', bin: 'devin', add: ['mcp', 'add', SERVER_NAME, ENDPOINT], remove: ['mcp', 'remove', SERVER_NAME] }],
|
|
137
172
|
signIn: `run devin mcp login ${SERVER_NAME}.`,
|
|
173
|
+
login: ['mcp', 'login', SERVER_NAME],
|
|
138
174
|
},
|
|
139
175
|
{
|
|
140
176
|
id: 'tools-goose', name: 'Goose',
|
|
141
|
-
detect: (h) => existsSync(
|
|
142
|
-
|
|
177
|
+
detect: (h) => existsSync(gooseDir(h)) || onPath('goose'),
|
|
178
|
+
// The streamable_http shape of Goose's ExtensionConfig (block/goose,
|
|
179
|
+
// crates/goose/src/agents/extension.rs; documentation/docs/guides/config-files.md).
|
|
180
|
+
user: (h, key) => [{ kind: 'yaml', path: join(gooseDir(h), 'config.yaml'), key: 'extensions', lines: [
|
|
181
|
+
'type: streamable_http', `name: ${SERVER_NAME}`, 'enabled: true', `uri: ${ENDPOINT}`,
|
|
182
|
+
'envs: {}', 'env_keys: []', key ? `headers: { Authorization: "Bearer ${key}" }` : 'headers: {}', 'timeout: 300', 'bundled: null', 'available_tools: []',
|
|
183
|
+
] }],
|
|
143
184
|
signIn: SIGN_IN,
|
|
144
185
|
},
|
|
145
186
|
];
|
package/dist/cli.js
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
// npx sekura@latest init
|
|
5
5
|
//
|
|
6
6
|
// Sekura's MCP server is remote (https://sekura.ai/mcp), so this package
|
|
7
|
-
// runs no server
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// runs no server: it writes the server's address into the tools on this
|
|
8
|
+
// machine, installs the skill that teaches an agent the quote-then-confirm
|
|
9
|
+
// rule, and then finishes getting started in the terminal. It signs in once
|
|
10
|
+
// on a sekura.ai page and gives the resulting key to each tool, so none of
|
|
11
|
+
// them asks, then helps with the GitHub App, a repository and the free scan.
|
|
11
12
|
import { Command } from 'commander';
|
|
12
13
|
import { createRequire } from 'node:module';
|
|
13
14
|
import { initCommand } from './commands/init.js';
|
|
14
15
|
import { statusCommand } from './commands/status.js';
|
|
15
16
|
import { uninstallCommand } from './commands/uninstall.js';
|
|
17
|
+
import { loginCommand, logoutCommand } from './commands/login.js';
|
|
16
18
|
import { DOCS } from './catalogue.js';
|
|
17
19
|
const { version } = createRequire(import.meta.url)('../package.json');
|
|
18
20
|
const program = new Command();
|
|
@@ -21,8 +23,10 @@ program
|
|
|
21
23
|
.description(`Connect your coding tool to Sekura.\n\n npx sekura@latest init\n\nDocumentation: ${DOCS}`)
|
|
22
24
|
.version(version)
|
|
23
25
|
.addCommand(initCommand, { isDefault: false })
|
|
26
|
+
.addCommand(loginCommand)
|
|
24
27
|
.addCommand(statusCommand)
|
|
25
|
-
.addCommand(uninstallCommand)
|
|
28
|
+
.addCommand(uninstallCommand)
|
|
29
|
+
.addCommand(logoutCommand);
|
|
26
30
|
try {
|
|
27
31
|
await program.parseAsync(process.argv);
|
|
28
32
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
// `sekura init`: detect, choose, review, apply, verify
|
|
2
|
-
//
|
|
1
|
+
// `sekura init`: detect, choose, sign in, review, apply, verify, then the
|
|
2
|
+
// rest of getting started (the GitHub App, a repository, the free scan) so
|
|
3
|
+
// none of it is left for the coding tool. Nothing is written before the
|
|
4
|
+
// review is shown and (unless --yes) agreed.
|
|
3
5
|
import { Command } from 'commander';
|
|
4
6
|
import { checkbox, confirm, select } from '@inquirer/prompts';
|
|
5
|
-
import {
|
|
7
|
+
import { realpathSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, resolve } from 'node:path';
|
|
10
|
+
import { DOCS, ENDPOINT, TOOLS, carriesKey, home } from '../catalogue.js';
|
|
6
11
|
import { applyStep, buildPlan, describeStep, detectTools } from '../plan.js';
|
|
7
12
|
import { verifyEndpoint } from '../verify.js';
|
|
13
|
+
import { checkAccount, howToScan, obtainKey, offerFreeScan, pickRepository, runToolLogins } from '../guide.js';
|
|
8
14
|
import { banner, cross, dot, say, tick } from '../ui.js';
|
|
15
|
+
/** The home folder (or the top of the disk) is no project: project scope
|
|
16
|
+
* there would put AGENTS.md and .mcp.json in it for every tool to find. */
|
|
17
|
+
export function isProjectDir(cwd) {
|
|
18
|
+
const real = (p) => { try {
|
|
19
|
+
return realpathSync(p);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return resolve(p);
|
|
23
|
+
} };
|
|
24
|
+
const dir = real(cwd);
|
|
25
|
+
return dir !== real(home()) && dir !== real(homedir()) && dirname(dir) !== dir;
|
|
26
|
+
}
|
|
9
27
|
export async function runInit(opts) {
|
|
10
28
|
const interactive = !opts.yes && process.stdin.isTTY && process.stderr.isTTY;
|
|
11
29
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -35,7 +53,13 @@ export async function runInit(opts) {
|
|
|
35
53
|
}
|
|
36
54
|
// 2. Scope and skill.
|
|
37
55
|
let scope = opts.scope ?? 'user';
|
|
38
|
-
|
|
56
|
+
const inProject = isProjectDir(cwd);
|
|
57
|
+
if (scope === 'project' && !inProject) {
|
|
58
|
+
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.`);
|
|
59
|
+
say(' Nothing was changed.');
|
|
60
|
+
return { written: 0, byHand: 0, failed: 1 };
|
|
61
|
+
}
|
|
62
|
+
if (!opts.scope && interactive && inProject) {
|
|
39
63
|
scope = await select({
|
|
40
64
|
message: 'Where',
|
|
41
65
|
choices: [
|
|
@@ -48,8 +72,16 @@ export async function runInit(opts) {
|
|
|
48
72
|
if (opts.skill === undefined && interactive) {
|
|
49
73
|
skill = await confirm({ message: 'Also install the sekura skill, which teaches the agent the quote-then-confirm rule?', default: true });
|
|
50
74
|
}
|
|
51
|
-
// 3.
|
|
52
|
-
|
|
75
|
+
// 3. Sign in once, here, so the key goes into each tool and none of them
|
|
76
|
+
// asks. Project files never carry it (they are committed), so project
|
|
77
|
+
// scope skips this. A dry run uses a key it already has, never a browser.
|
|
78
|
+
let session = null;
|
|
79
|
+
if (opts.login !== false && scope === 'user') {
|
|
80
|
+
say();
|
|
81
|
+
session = await obtainKey({ interactive: interactive && !opts.dryRun });
|
|
82
|
+
}
|
|
83
|
+
// 4. The plan, shown before anything happens.
|
|
84
|
+
const plan = buildPlan({ tools: chosen, scope, skill, cwd, key: session?.key });
|
|
53
85
|
say();
|
|
54
86
|
say(opts.dryRun ? ' Dry run. This would:' : ' This will:');
|
|
55
87
|
for (const s of plan.steps)
|
|
@@ -64,7 +96,7 @@ export async function runInit(opts) {
|
|
|
64
96
|
say(' Nothing was changed.');
|
|
65
97
|
return { written: 0, byHand: 0, failed: 0 };
|
|
66
98
|
}
|
|
67
|
-
//
|
|
99
|
+
// 5. Apply, one step at a time, saying what each did.
|
|
68
100
|
let written = 0, byHand = 0, failed = 0;
|
|
69
101
|
const signIns = new Map();
|
|
70
102
|
for (const s of plan.steps) {
|
|
@@ -92,20 +124,41 @@ export async function runInit(opts) {
|
|
|
92
124
|
break;
|
|
93
125
|
}
|
|
94
126
|
if (s.kind === 'tool' && (r.status === 'written' || r.status === 'ran' || r.status === 'unchanged'))
|
|
95
|
-
signIns.set(s.tool.name, s.tool.signIn);
|
|
127
|
+
signIns.set(s.tool.name, carriesKey(s.target) ? null : s.tool.signIn);
|
|
96
128
|
}
|
|
97
|
-
//
|
|
129
|
+
// Tools whose entry cannot carry the key sign in with their own command,
|
|
130
|
+
// now, while the browser is signed in to sekura.ai.
|
|
131
|
+
if (session)
|
|
132
|
+
for (const name of await runToolLogins(plan.steps.filter((s) => s.kind === 'tool' && signIns.has(s.tool.name)), interactive))
|
|
133
|
+
signIns.set(name, null);
|
|
134
|
+
// 6. Verify the address.
|
|
98
135
|
if (opts.verify !== false) {
|
|
99
136
|
const v = await verifyEndpoint(ENDPOINT);
|
|
100
137
|
say(`${v.ok ? tick : cross} ${v.detail}`);
|
|
101
138
|
}
|
|
139
|
+
// 7. The account, a repository and the free scan, with the key.
|
|
140
|
+
let repo = null;
|
|
141
|
+
if (session && signIns.size) {
|
|
142
|
+
say();
|
|
143
|
+
await checkAccount(session, interactive);
|
|
144
|
+
repo = await pickRepository(session, cwd, interactive);
|
|
145
|
+
await offerFreeScan(session, repo, interactive);
|
|
146
|
+
}
|
|
147
|
+
// 8. What is left, per tool.
|
|
102
148
|
say();
|
|
103
149
|
if (signIns.size) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
const ready = [...signIns].filter(([, how]) => how === null).map(([name]) => name);
|
|
151
|
+
const pending = [...signIns].filter(([, how]) => how !== null);
|
|
152
|
+
if (ready.length)
|
|
153
|
+
say(`${tick} Ready, no sign-in needed: ${ready.join(', ')}`);
|
|
154
|
+
if (pending.length) {
|
|
155
|
+
say(' Sign in once per tool:');
|
|
156
|
+
for (const [name, how] of pending)
|
|
157
|
+
say(`${dot} ${name}: ${how}`);
|
|
158
|
+
}
|
|
159
|
+
say();
|
|
160
|
+
howToScan([...signIns.keys()], repo);
|
|
107
161
|
say();
|
|
108
|
-
say(' Then ask it which of your repositories Sekura can scan, and for a quote to scan one.');
|
|
109
162
|
}
|
|
110
163
|
say(` Documentation and every other tool: ${DOCS}`);
|
|
111
164
|
say();
|
|
@@ -113,13 +166,14 @@ export async function runInit(opts) {
|
|
|
113
166
|
}
|
|
114
167
|
export const initCommand = new Command('init')
|
|
115
168
|
.description('Connect the coding tools on this machine to Sekura, and install the sekura skill.')
|
|
116
|
-
.option('-y, --yes', 'apply without asking; every detected tool, user scope, with the skill')
|
|
169
|
+
.option('-y, --yes', 'apply without asking; every detected tool, user scope, with the skill, and the key in SEKURA_API_KEY or saved by sekura login')
|
|
117
170
|
.option('--no-skill', 'the MCP server only: no skill, no guidance block')
|
|
118
171
|
.option('--scope <scope>', 'user (every project) or project (this directory)', (v) => { if (v !== 'user' && v !== 'project')
|
|
119
172
|
throw new Error('--scope is user or project'); return v; })
|
|
120
173
|
.option('--tools <ids>', `only these, comma-separated: ${TOOLS.map((t) => t.id.replace('tools-', '')).join(', ')}`)
|
|
121
174
|
.option('--dry-run', 'show the plan and change nothing')
|
|
122
175
|
.option('--no-verify', 'do not check that the Sekura server answers')
|
|
176
|
+
.option('--no-login', 'do not sign in here; each tool signs in on first use')
|
|
123
177
|
.action(async (opts) => {
|
|
124
178
|
const r = await runInit(opts);
|
|
125
179
|
process.exitCode = r.failed ? 1 : 0;
|