@ahmednawaz/crank 0.1.2
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/.env.example +53 -0
- package/README.md +266 -0
- package/bin/crank.js +737 -0
- package/bookmarklet/crank-bookmarklet.js +2295 -0
- package/bookmarklet/crank-ui-helpers.js +355 -0
- package/bookmarklet/install-snippet.txt +5 -0
- package/bookmarklet/text-source.js +239 -0
- package/companion/agent-queue.js +485 -0
- package/companion/agent-runner.js +334 -0
- package/companion/bin/crank.js +69 -0
- package/companion/dev-loader.js +39 -0
- package/companion/glean-client.js +991 -0
- package/companion/package.json +18 -0
- package/companion/persist-apply.js +189 -0
- package/companion/selection-store.js +175 -0
- package/companion/server.js +1147 -0
- package/companion/text-dispatch.js +419 -0
- package/companion/vizpatch-bridge.js +49 -0
- package/lib/copy-labels.js +86 -0
- package/lib/detect.js +88 -0
- package/lib/env.js +23 -0
- package/lib/init.js +435 -0
- package/lib/load-team-env.js +43 -0
- package/lib/resolve-project.js +203 -0
- package/lib/team-auth.js +82 -0
- package/lib/urls.js +164 -0
- package/mcp-server/index.js +174 -0
- package/mcp-server/package.json +16 -0
- package/mcp-server/tools.js +480 -0
- package/package.json +57 -0
- package/panel/demo.html +62 -0
- package/skill/SKILL.md +60 -0
- package/skills/README.md +16 -0
- package/skills/copy-guidance.md +25 -0
- package/team.env +4 -0
- package/vite.js +75 -0
package/lib/team-auth.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const TEAM_TOKEN = String(process.env.CRANK_TEAM_TOKEN || '').trim();
|
|
4
|
+
|
|
5
|
+
function teamAuthRequired() {
|
|
6
|
+
return TEAM_TOKEN.length > 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extractTeamToken(req) {
|
|
10
|
+
if (!req) return '';
|
|
11
|
+
const header =
|
|
12
|
+
req.get?.('x-crank-team-token') ||
|
|
13
|
+
req.headers?.['x-crank-team-token'] ||
|
|
14
|
+
req.headers?.['X-Crank-Team-Token'];
|
|
15
|
+
if (header) return String(header).trim();
|
|
16
|
+
|
|
17
|
+
const auth = req.get?.('authorization') || req.headers?.authorization || '';
|
|
18
|
+
const bearer = auth.replace(/^Bearer\s+/i, '').trim();
|
|
19
|
+
if (bearer) return bearer;
|
|
20
|
+
|
|
21
|
+
if (req.body && typeof req.body.teamToken === 'string') {
|
|
22
|
+
return req.body.teamToken.trim();
|
|
23
|
+
}
|
|
24
|
+
if (req.query && req.query.teamToken) {
|
|
25
|
+
return String(req.query.teamToken).trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const raw = req.url || '';
|
|
30
|
+
const q = raw.indexOf('?');
|
|
31
|
+
if (q >= 0) {
|
|
32
|
+
const params = new URLSearchParams(raw.slice(q + 1));
|
|
33
|
+
return (
|
|
34
|
+
params.get('teamToken') ||
|
|
35
|
+
params.get('token') ||
|
|
36
|
+
''
|
|
37
|
+
).trim();
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore
|
|
41
|
+
}
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTeamTokenValid(token) {
|
|
46
|
+
if (!teamAuthRequired()) return true;
|
|
47
|
+
return String(token || '').trim() === TEAM_TOKEN;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function teamAuthMiddleware(req, res, next) {
|
|
51
|
+
if (!teamAuthRequired()) {
|
|
52
|
+
next();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (isTeamTokenValid(extractTeamToken(req))) {
|
|
56
|
+
next();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
res.status(401).json({
|
|
60
|
+
ok: false,
|
|
61
|
+
code: 'TEAM_TOKEN_REQUIRED',
|
|
62
|
+
error:
|
|
63
|
+
'Crank team token required. Ask your team for the passphrase, then pass header X-Crank-Team-Token or set CRANK_TEAM_TOKEN for the agent.'
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getAuthStatus() {
|
|
68
|
+
return {
|
|
69
|
+
teamAuthRequired: teamAuthRequired(),
|
|
70
|
+
hint: teamAuthRequired()
|
|
71
|
+
? 'Ask your team lead for the Crank team token. Agents: pass teamToken on MCP tools or set CRANK_TEAM_TOKEN in the environment.'
|
|
72
|
+
: null
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
teamAuthRequired,
|
|
78
|
+
extractTeamToken,
|
|
79
|
+
isTeamTokenValid,
|
|
80
|
+
teamAuthMiddleware,
|
|
81
|
+
getAuthStatus
|
|
82
|
+
};
|
package/lib/urls.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { readJson } = require('./detect');
|
|
6
|
+
const { env } = require('./env');
|
|
7
|
+
|
|
8
|
+
function companionPort() {
|
|
9
|
+
return Number(env('PORT', '3344')) || 3344;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mcpHttpPort() {
|
|
13
|
+
return Number(env('MCP_HTTP_PORT', '3345')) || 3345;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Public HTTPS host for Replit exposed ports.
|
|
18
|
+
* Pattern: https://{port}-{replSlug}.{owner}.repl.co
|
|
19
|
+
*/
|
|
20
|
+
function replitPortHost(port) {
|
|
21
|
+
const explicit = env('PUBLIC_URL', '');
|
|
22
|
+
if (explicit) {
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(explicit);
|
|
25
|
+
return `${u.protocol}//${port}-${u.host}`;
|
|
26
|
+
} catch {
|
|
27
|
+
// fall through
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const devDomain = process.env.REPLIT_DEV_DOMAIN || '';
|
|
32
|
+
if (devDomain) {
|
|
33
|
+
const host = devDomain.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
34
|
+
if (/^\d+-/.test(host)) {
|
|
35
|
+
return `https://${port}-${host.replace(/^\d+-/, '')}`;
|
|
36
|
+
}
|
|
37
|
+
return `https://${port}-${host}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const slug = process.env.REPL_SLUG;
|
|
41
|
+
const owner = process.env.REPL_OWNER;
|
|
42
|
+
if (slug && owner) {
|
|
43
|
+
return `https://${port}-${slug}.${owner}.repl.co`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve listen + public URLs for companion and HTTP MCP.
|
|
51
|
+
*/
|
|
52
|
+
function resolveUrls(detectEnv = {}) {
|
|
53
|
+
const port = companionPort();
|
|
54
|
+
const mcpPort = mcpHttpPort();
|
|
55
|
+
const isReplit = Boolean(detectEnv.isReplit);
|
|
56
|
+
const publicOverride = env('PUBLIC_URL', '');
|
|
57
|
+
|
|
58
|
+
let listenHost = env('HOST', isReplit ? '0.0.0.0' : '127.0.0.1');
|
|
59
|
+
if (listenHost === 'localhost') {
|
|
60
|
+
listenHost = '127.0.0.1';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let companionPublic;
|
|
64
|
+
let mcpPublic;
|
|
65
|
+
|
|
66
|
+
if (publicOverride) {
|
|
67
|
+
companionPublic = publicOverride.replace(/\/$/, '');
|
|
68
|
+
if (isReplit) {
|
|
69
|
+
mcpPublic = replitPortHost(mcpPort) || `http://127.0.0.1:${mcpPort}`;
|
|
70
|
+
} else {
|
|
71
|
+
mcpPublic = `http://127.0.0.1:${mcpPort}`;
|
|
72
|
+
}
|
|
73
|
+
} else if (isReplit) {
|
|
74
|
+
companionPublic = replitPortHost(port) || `http://127.0.0.1:${port}`;
|
|
75
|
+
mcpPublic = replitPortHost(mcpPort) || `http://127.0.0.1:${mcpPort}`;
|
|
76
|
+
} else {
|
|
77
|
+
companionPublic = `http://127.0.0.1:${port}`;
|
|
78
|
+
mcpPublic = `http://127.0.0.1:${mcpPort}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const companionLocal = `http://127.0.0.1:${port}`;
|
|
82
|
+
const mcpLocal = `http://127.0.0.1:${mcpPort}`;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
isReplit,
|
|
86
|
+
listenHost,
|
|
87
|
+
companionPort: port,
|
|
88
|
+
mcpHttpPort: mcpPort,
|
|
89
|
+
companionPublic,
|
|
90
|
+
companionLocal,
|
|
91
|
+
companionWs: companionPublic.replace(/^http/i, 'ws'),
|
|
92
|
+
mcpPublic,
|
|
93
|
+
mcpLocal,
|
|
94
|
+
mcpEndpoint: `${mcpPublic}/mcp`
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readRuntimeManifest(projectRoot) {
|
|
99
|
+
const file = path.join(projectRoot, '.crank', 'runtime.json');
|
|
100
|
+
if (!fs.existsSync(file)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return readJson(file, null);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeRuntimeManifest(projectRoot, detectEnv) {
|
|
107
|
+
const urls = resolveUrls(detectEnv);
|
|
108
|
+
const dir = path.join(projectRoot, '.crank');
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
110
|
+
const manifest = {
|
|
111
|
+
version: 1,
|
|
112
|
+
updatedAt: new Date().toISOString(),
|
|
113
|
+
projectRoot: path.resolve(projectRoot),
|
|
114
|
+
environment: {
|
|
115
|
+
isReplit: urls.isReplit,
|
|
116
|
+
hasVite: Boolean(detectEnv.hasVite)
|
|
117
|
+
},
|
|
118
|
+
companion: {
|
|
119
|
+
listenHost: urls.listenHost,
|
|
120
|
+
port: urls.companionPort,
|
|
121
|
+
publicUrl: urls.companionPublic,
|
|
122
|
+
localUrl: urls.companionLocal,
|
|
123
|
+
wsUrl: urls.companionWs,
|
|
124
|
+
devLoader: `${urls.companionPublic}/dev-loader.js`,
|
|
125
|
+
bookmarklet: `${urls.companionPublic}/bookmarklet.js`
|
|
126
|
+
},
|
|
127
|
+
mcp: {
|
|
128
|
+
mode:
|
|
129
|
+
urls.isReplit || String(process.env.CRANK_MCP_MODE || '').toLowerCase() === 'http'
|
|
130
|
+
? 'http'
|
|
131
|
+
: 'stdio',
|
|
132
|
+
httpPort: urls.mcpHttpPort,
|
|
133
|
+
publicUrl: urls.mcpPublic,
|
|
134
|
+
localUrl: urls.mcpLocal,
|
|
135
|
+
endpoint: urls.mcpEndpoint
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
const file = path.join(dir, 'runtime.json');
|
|
139
|
+
fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
140
|
+
return manifest;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function applyUrlEnv(urls) {
|
|
144
|
+
process.env.CRANK_HOST = urls.listenHost;
|
|
145
|
+
process.env.CRANK_PORT = String(urls.companionPort);
|
|
146
|
+
process.env.HOST = urls.listenHost;
|
|
147
|
+
process.env.PORT = String(urls.companionPort);
|
|
148
|
+
process.env.CRANK_COMPANION_URL = urls.companionLocal;
|
|
149
|
+
process.env.CRANK_PUBLIC_URL = urls.companionPublic;
|
|
150
|
+
process.env.CRANK_MCP_HTTP_PORT = String(urls.mcpHttpPort);
|
|
151
|
+
if (urls.isReplit) {
|
|
152
|
+
process.env.CRANK_MCP_MODE = 'http';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
companionPort,
|
|
158
|
+
mcpHttpPort,
|
|
159
|
+
resolveUrls,
|
|
160
|
+
readRuntimeManifest,
|
|
161
|
+
writeRuntimeManifest,
|
|
162
|
+
applyUrlEnv,
|
|
163
|
+
replitPortHost
|
|
164
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Crank MCP server
|
|
4
|
+
*
|
|
5
|
+
* Modes:
|
|
6
|
+
* CRANK_MCP_MODE=stdio (default) — Cursor / Claude Desktop local MCP
|
|
7
|
+
* CRANK_MCP_MODE=http — remote MCP for Replit Agent (HTTPS endpoint required in prod)
|
|
8
|
+
*
|
|
9
|
+
* Sources:
|
|
10
|
+
* Replit custom MCP: https://docs.replit.com/build/connect-via-mcp
|
|
11
|
+
* Replit MCP list / auth headers: https://docs.replit.com/references/mcp/overview
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import {
|
|
17
|
+
CallToolRequestSchema,
|
|
18
|
+
ListToolsRequestSchema
|
|
19
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
20
|
+
import express from 'express';
|
|
21
|
+
import { toolDefinitions, callTool } from './tools.js';
|
|
22
|
+
|
|
23
|
+
const MODE = (process.env.CRANK_MCP_MODE || 'stdio').toLowerCase();
|
|
24
|
+
const HTTP_PORT = Number(process.env.CRANK_MCP_HTTP_PORT || 3345);
|
|
25
|
+
const API_KEY = process.env.CRANK_MCP_API_KEY || '';
|
|
26
|
+
|
|
27
|
+
function createMcpServer() {
|
|
28
|
+
const server = new Server(
|
|
29
|
+
{ name: 'crank', version: '0.1.2' },
|
|
30
|
+
{ capabilities: { tools: {} } }
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
34
|
+
tools: toolDefinitions
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
38
|
+
const name = request.params.name;
|
|
39
|
+
const args = request.params.arguments || {};
|
|
40
|
+
try {
|
|
41
|
+
return await callTool(name, args);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return {
|
|
44
|
+
isError: true,
|
|
45
|
+
content: [{ type: 'text', text: String(err.message || err) }]
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return server;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function startStdio() {
|
|
54
|
+
const server = createMcpServer();
|
|
55
|
+
const transport = new StdioServerTransport();
|
|
56
|
+
await server.connect(transport);
|
|
57
|
+
console.error('[Crank MCP] stdio transport ready');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Minimal Streamable HTTP / JSON-RPC style endpoint for remote agents.
|
|
62
|
+
* Replit expects an HTTPS MCP endpoint — tunnel or deploy this service.
|
|
63
|
+
* Auth: optional X-API-Key / Authorization bearer via CRANK_MCP_API_KEY.
|
|
64
|
+
*/
|
|
65
|
+
async function startHttp() {
|
|
66
|
+
const app = express();
|
|
67
|
+
app.use(express.json({ limit: '2mb' }));
|
|
68
|
+
|
|
69
|
+
function authorize(req, res, next) {
|
|
70
|
+
if (!API_KEY) {
|
|
71
|
+
next();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const headerKey =
|
|
75
|
+
req.get('x-api-key') ||
|
|
76
|
+
(req.get('authorization') || '').replace(/^Bearer\s+/i, '');
|
|
77
|
+
if (headerKey !== API_KEY) {
|
|
78
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
next();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
app.get('/health', (_req, res) => {
|
|
85
|
+
res.json({ ok: true, service: 'crank-mcp', mode: 'http' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Simple JSON tool surface (also useful for demos / curl).
|
|
89
|
+
// Full MCP streamable HTTP can replace this once the Motives hosting shape is fixed.
|
|
90
|
+
app.get('/mcp/tools', authorize, (_req, res) => {
|
|
91
|
+
res.json({ tools: toolDefinitions });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
app.post('/mcp/call', authorize, async (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const name = req.body?.name;
|
|
97
|
+
const args = req.body?.arguments || {};
|
|
98
|
+
const result = await callTool(name, args);
|
|
99
|
+
res.json({ ok: true, result });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
res.status(400).json({ ok: false, error: err.message });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// MCP-ish initialize / tools/list / tools/call over JSON-RPC for remote agents
|
|
106
|
+
app.post('/mcp', authorize, async (req, res) => {
|
|
107
|
+
const body = req.body || {};
|
|
108
|
+
const method = body.method;
|
|
109
|
+
const id = body.id ?? null;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
if (method === 'initialize') {
|
|
113
|
+
res.json({
|
|
114
|
+
jsonrpc: '2.0',
|
|
115
|
+
id,
|
|
116
|
+
result: {
|
|
117
|
+
protocolVersion: '2024-11-05',
|
|
118
|
+
capabilities: { tools: {} },
|
|
119
|
+
serverInfo: { name: 'crank', version: '0.1.0' }
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (method === 'tools/list') {
|
|
125
|
+
res.json({
|
|
126
|
+
jsonrpc: '2.0',
|
|
127
|
+
id,
|
|
128
|
+
result: { tools: toolDefinitions }
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (method === 'tools/call') {
|
|
133
|
+
const name = body.params?.name;
|
|
134
|
+
const args = body.params?.arguments || {};
|
|
135
|
+
const result = await callTool(name, args);
|
|
136
|
+
res.json({ jsonrpc: '2.0', id, result });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (method === 'notifications/initialized' || method === 'ping') {
|
|
140
|
+
res.json({ jsonrpc: '2.0', id, result: {} });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
res.json({
|
|
144
|
+
jsonrpc: '2.0',
|
|
145
|
+
id,
|
|
146
|
+
error: { code: -32601, message: `Method not found: ${method}` }
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
res.json({
|
|
150
|
+
jsonrpc: '2.0',
|
|
151
|
+
id,
|
|
152
|
+
error: { code: -32000, message: String(err.message || err) }
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Also wire stdio SDK server for local debugging in parallel? No — HTTP only here.
|
|
158
|
+
const listenHost =
|
|
159
|
+
process.env.CRANK_HOST === '0.0.0.0' || process.env.REPL_ID ? '0.0.0.0' : '127.0.0.1';
|
|
160
|
+
app.listen(HTTP_PORT, listenHost, () => {
|
|
161
|
+
console.error(
|
|
162
|
+
`[Crank MCP] HTTP listening on http://${listenHost}:${HTTP_PORT}/mcp`
|
|
163
|
+
);
|
|
164
|
+
console.error(
|
|
165
|
+
'[Crank MCP] For Replit: expose this URL over HTTPS and add as custom MCP server'
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (MODE === 'http') {
|
|
171
|
+
await startHttp();
|
|
172
|
+
} else {
|
|
173
|
+
await startStdio();
|
|
174
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crank/mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node index.js",
|
|
9
|
+
"start:http": "CRANK_MCP_MODE=http node index.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
13
|
+
"express": "^4.21.2",
|
|
14
|
+
"zod": "^3.24.2"
|
|
15
|
+
}
|
|
16
|
+
}
|