@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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crank/companion",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"crank-companion": "bin/crank.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node server.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@cursor/sdk": "^1.0.23",
|
|
14
|
+
"cors": "^2.8.5",
|
|
15
|
+
"express": "^4.21.2",
|
|
16
|
+
"ws": "^8.18.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persist an applied variant to source via Vizpatch text-writer (SAVE_TEXT_CHANGE).
|
|
5
|
+
* Skips locked/dynamic nodes with an explicit reason — never silent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { saveTextChange, pathHintsFromUrl } = require('./text-dispatch');
|
|
9
|
+
|
|
10
|
+
function roleLabel(role) {
|
|
11
|
+
const r = String(role || 'text').toLowerCase();
|
|
12
|
+
if (r === 'heading') return 'Heading';
|
|
13
|
+
if (r === 'action' || r === 'button') return 'CTA';
|
|
14
|
+
if (r === 'label') return 'Label';
|
|
15
|
+
return 'Body';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} session - session AFTER store.applyVariant (nodes already updated)
|
|
20
|
+
* @param {object} variant
|
|
21
|
+
* @param {string} projectRoot
|
|
22
|
+
* @param {object} [options]
|
|
23
|
+
* @param {object[]} [options.preApplyNodes] - session.nodes snapshot before apply
|
|
24
|
+
*/
|
|
25
|
+
function persistAppliedVariant(session, variant, projectRoot, options = {}) {
|
|
26
|
+
const preNodes = Array.isArray(options.preApplyNodes)
|
|
27
|
+
? options.preApplyNodes
|
|
28
|
+
: session?.nodes || [];
|
|
29
|
+
const preByPath = new Map(preNodes.map((n) => [String(n.path), n]));
|
|
30
|
+
const variantNodes = Array.isArray(variant?.nodes) ? variant.nodes : [];
|
|
31
|
+
|
|
32
|
+
const results = [];
|
|
33
|
+
let saved = 0;
|
|
34
|
+
let skipped = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
|
|
37
|
+
for (const vn of variantNodes) {
|
|
38
|
+
const path = String(vn.path);
|
|
39
|
+
const pre =
|
|
40
|
+
preByPath.get(path) ||
|
|
41
|
+
(vn.id
|
|
42
|
+
? preNodes.find((n) => n && String(n.id) === String(vn.id))
|
|
43
|
+
: null) ||
|
|
44
|
+
{};
|
|
45
|
+
const label = roleLabel(vn.role || pre.role);
|
|
46
|
+
const newText = String(vn.text != null ? vn.text : '');
|
|
47
|
+
const oldText = String(
|
|
48
|
+
pre.originalText != null
|
|
49
|
+
? pre.originalText
|
|
50
|
+
: pre.previousText != null
|
|
51
|
+
? pre.previousText
|
|
52
|
+
: pre.text != null
|
|
53
|
+
? pre.text
|
|
54
|
+
: ''
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (oldText === newText) {
|
|
58
|
+
results.push({
|
|
59
|
+
path,
|
|
60
|
+
role: vn.role || pre.role,
|
|
61
|
+
label,
|
|
62
|
+
status: 'unchanged',
|
|
63
|
+
message: 'No text change'
|
|
64
|
+
});
|
|
65
|
+
skipped += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const editable = pre.editable !== false && vn.editable !== false;
|
|
70
|
+
const sourceType =
|
|
71
|
+
(pre.textSource && pre.textSource.type) ||
|
|
72
|
+
(vn.textSource && vn.textSource.type) ||
|
|
73
|
+
'';
|
|
74
|
+
|
|
75
|
+
if (!editable) {
|
|
76
|
+
results.push({
|
|
77
|
+
path,
|
|
78
|
+
role: vn.role || pre.role,
|
|
79
|
+
label,
|
|
80
|
+
status: 'skipped',
|
|
81
|
+
reason: 'locked',
|
|
82
|
+
message:
|
|
83
|
+
sourceType === 'dynamic-data'
|
|
84
|
+
? 'Dynamic/bound data — cannot write hardcoded source'
|
|
85
|
+
: sourceType === 'bound-unknown'
|
|
86
|
+
? 'Bound text — cannot save to source'
|
|
87
|
+
: 'Node is not editable for source save'
|
|
88
|
+
});
|
|
89
|
+
skipped += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const selectorText =
|
|
94
|
+
vn.selector || pre.selector || session.selector || '';
|
|
95
|
+
if (!selectorText) {
|
|
96
|
+
results.push({
|
|
97
|
+
path,
|
|
98
|
+
role: vn.role || pre.role,
|
|
99
|
+
label,
|
|
100
|
+
status: 'failed',
|
|
101
|
+
message: 'No CSS selector available for text save'
|
|
102
|
+
});
|
|
103
|
+
failed += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!oldText) {
|
|
108
|
+
results.push({
|
|
109
|
+
path,
|
|
110
|
+
role: vn.role || pre.role,
|
|
111
|
+
label,
|
|
112
|
+
status: 'failed',
|
|
113
|
+
message:
|
|
114
|
+
'Missing originalText/oldValue for source resolve — re-select the element'
|
|
115
|
+
});
|
|
116
|
+
failed += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pageUrl = session.pageUrl || session.meta?.pageUrl || '';
|
|
121
|
+
const pathHints = []
|
|
122
|
+
.concat(Array.isArray(session.meta?.pathHints) ? session.meta.pathHints : [])
|
|
123
|
+
.concat(pathHintsFromUrl(pageUrl));
|
|
124
|
+
|
|
125
|
+
const saveResult = saveTextChange(
|
|
126
|
+
{
|
|
127
|
+
selectorText,
|
|
128
|
+
value: newText,
|
|
129
|
+
oldValue: oldText,
|
|
130
|
+
sourceUrl: pageUrl,
|
|
131
|
+
origin: session.origin || '',
|
|
132
|
+
pageUrl,
|
|
133
|
+
pathHints,
|
|
134
|
+
file: session.meta?.file || pre.file || undefined,
|
|
135
|
+
componentName:
|
|
136
|
+
session.meta?.componentName ||
|
|
137
|
+
pre.componentName ||
|
|
138
|
+
vn.componentName ||
|
|
139
|
+
undefined
|
|
140
|
+
},
|
|
141
|
+
projectRoot
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (saveResult.success) {
|
|
145
|
+
saved += 1;
|
|
146
|
+
results.push({
|
|
147
|
+
path,
|
|
148
|
+
role: vn.role || pre.role,
|
|
149
|
+
label,
|
|
150
|
+
status: 'saved',
|
|
151
|
+
file: saveResult.file,
|
|
152
|
+
message: `Saved to ${saveResult.file || 'source'}`
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
failed += 1;
|
|
156
|
+
results.push({
|
|
157
|
+
path,
|
|
158
|
+
role: vn.role || pre.role,
|
|
159
|
+
label,
|
|
160
|
+
status: 'failed',
|
|
161
|
+
file: saveResult.file,
|
|
162
|
+
message: saveResult.error || 'Save failed',
|
|
163
|
+
attemptedFiles: saveResult.attemptedFiles
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const summaryParts = [];
|
|
169
|
+
if (saved) summaryParts.push(`saved ${saved}`);
|
|
170
|
+
if (failed) summaryParts.push(`failed ${failed}`);
|
|
171
|
+
if (skipped) summaryParts.push(`skipped ${skipped}`);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
ok: failed === 0,
|
|
175
|
+
persisted: saved > 0,
|
|
176
|
+
saved,
|
|
177
|
+
failed,
|
|
178
|
+
skipped,
|
|
179
|
+
results,
|
|
180
|
+
summary:
|
|
181
|
+
summaryParts.length > 0
|
|
182
|
+
? `Source: ${summaryParts.join(', ')}`
|
|
183
|
+
: 'Source: nothing to write'
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
persistAppliedVariant
|
|
189
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory selection + variant session store.
|
|
5
|
+
* Browser bookmarklet writes here; MCP tools and Glean client read/write here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const { assignUnitLabels } = require('../lib/copy-labels');
|
|
10
|
+
|
|
11
|
+
/** @type {Map<string, object>} */
|
|
12
|
+
const sessions = new Map();
|
|
13
|
+
|
|
14
|
+
let activeSessionId = null;
|
|
15
|
+
|
|
16
|
+
function createSessionId() {
|
|
17
|
+
return crypto.randomBytes(8).toString('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} payload
|
|
22
|
+
* @returns {object}
|
|
23
|
+
*/
|
|
24
|
+
function normalizeNodes(nodes) {
|
|
25
|
+
return assignUnitLabels(
|
|
26
|
+
(Array.isArray(nodes) ? nodes : []).map((n, i) => {
|
|
27
|
+
const text = n && n.text != null ? String(n.text) : '';
|
|
28
|
+
const path = n && n.path != null ? String(n.path) : String(i);
|
|
29
|
+
return {
|
|
30
|
+
...n,
|
|
31
|
+
id: (n && n.id) || `copy-${path}`,
|
|
32
|
+
path,
|
|
33
|
+
text,
|
|
34
|
+
previousText: n.previousText != null ? String(n.previousText) : text,
|
|
35
|
+
// Freeze source baseline for SAVE_TEXT_CHANGE oldValue across applies.
|
|
36
|
+
originalText:
|
|
37
|
+
n.originalText != null ? String(n.originalText) : text
|
|
38
|
+
};
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setSelection(payload) {
|
|
44
|
+
const id = createSessionId();
|
|
45
|
+
const nodes = normalizeNodes(payload.nodes);
|
|
46
|
+
const session = {
|
|
47
|
+
id,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
updatedAt: new Date().toISOString(),
|
|
50
|
+
origin: payload.origin || '',
|
|
51
|
+
pageUrl: payload.pageUrl || '',
|
|
52
|
+
selector: payload.selector || '',
|
|
53
|
+
tagName: payload.tagName || '',
|
|
54
|
+
nodes,
|
|
55
|
+
combinedText: payload.combinedText || nodes.map((n) => n.text).join('\n'),
|
|
56
|
+
variants: [],
|
|
57
|
+
appliedVariantId: null,
|
|
58
|
+
meta: payload.meta || {}
|
|
59
|
+
};
|
|
60
|
+
sessions.set(id, session);
|
|
61
|
+
activeSessionId = id;
|
|
62
|
+
return session;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getActiveSession() {
|
|
66
|
+
if (!activeSessionId) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return sessions.get(activeSessionId) || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getSession(id) {
|
|
73
|
+
if (!id) {
|
|
74
|
+
return getActiveSession();
|
|
75
|
+
}
|
|
76
|
+
return sessions.get(id) || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function listSessions() {
|
|
80
|
+
return Array.from(sessions.values()).sort((a, b) =>
|
|
81
|
+
String(b.updatedAt).localeCompare(String(a.updatedAt))
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} sessionId
|
|
87
|
+
* @param {object[]} variants
|
|
88
|
+
*/
|
|
89
|
+
function setVariants(sessionId, variants) {
|
|
90
|
+
const session = getSession(sessionId);
|
|
91
|
+
if (!session) {
|
|
92
|
+
throw new Error(`Unknown session: ${sessionId}`);
|
|
93
|
+
}
|
|
94
|
+
session.variants = Array.isArray(variants) ? variants : [];
|
|
95
|
+
session.updatedAt = new Date().toISOString();
|
|
96
|
+
sessions.set(session.id, session);
|
|
97
|
+
activeSessionId = session.id;
|
|
98
|
+
return session;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Apply a variant's node map onto the stored selection (source of truth for MCP).
|
|
103
|
+
* Live DOM apply is pushed to connected browsers via WebSocket by the server.
|
|
104
|
+
*/
|
|
105
|
+
function applyVariant(sessionId, variantId) {
|
|
106
|
+
const session = getSession(sessionId);
|
|
107
|
+
if (!session) {
|
|
108
|
+
throw new Error(`Unknown session: ${sessionId}`);
|
|
109
|
+
}
|
|
110
|
+
const variant = (session.variants || []).find((v) => v.id === variantId);
|
|
111
|
+
if (!variant) {
|
|
112
|
+
throw new Error(`Unknown variant: ${variantId}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const byPath = new Map(
|
|
116
|
+
(variant.nodes || []).map((n) => [String(n.path), n])
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
session.nodes = (session.nodes || []).map((node) => {
|
|
120
|
+
const next =
|
|
121
|
+
byPath.get(String(node.path)) ||
|
|
122
|
+
(node.id
|
|
123
|
+
? (variant.nodes || []).find((n) => String(n.id) === String(node.id))
|
|
124
|
+
: null);
|
|
125
|
+
if (!next) {
|
|
126
|
+
return node;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
...node,
|
|
130
|
+
id: node.id || next.id || `copy-${node.path}`,
|
|
131
|
+
text: next.text,
|
|
132
|
+
previousText: node.text,
|
|
133
|
+
originalText:
|
|
134
|
+
node.originalText != null ? node.originalText : node.text
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
session.combinedText = session.nodes.map((n) => n.text).join('\n');
|
|
138
|
+
session.appliedVariantId = variantId;
|
|
139
|
+
session.updatedAt = new Date().toISOString();
|
|
140
|
+
sessions.set(session.id, session);
|
|
141
|
+
activeSessionId = session.id;
|
|
142
|
+
return { session, variant };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function clearSessions() {
|
|
146
|
+
sessions.clear();
|
|
147
|
+
activeSessionId = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function setGleanProgress(sessionId, progress) {
|
|
151
|
+
const session = getSession(sessionId);
|
|
152
|
+
if (!session) return null;
|
|
153
|
+
session.gleanProgress = progress || null;
|
|
154
|
+
session.gleanProgressAt = new Date().toISOString();
|
|
155
|
+
sessions.set(session.id, session);
|
|
156
|
+
return session.gleanProgress;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getGleanProgress(sessionId) {
|
|
160
|
+
const session = getSession(sessionId);
|
|
161
|
+
if (!session) return null;
|
|
162
|
+
return session.gleanProgress || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
setSelection,
|
|
167
|
+
getActiveSession,
|
|
168
|
+
getSession,
|
|
169
|
+
listSessions,
|
|
170
|
+
setVariants,
|
|
171
|
+
applyVariant,
|
|
172
|
+
clearSessions,
|
|
173
|
+
setGleanProgress,
|
|
174
|
+
getGleanProgress
|
|
175
|
+
};
|