codeply-cli 0.2.0 → 0.3.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/THIRD_PARTY_NOTICES.md +63 -0
- package/bin/codeply.js +558 -370
- package/lib/agent.mjs +1627 -160
- package/lib/ai.js +1107 -387
- package/lib/apply-limit.js +33 -51
- package/lib/apply-patch.js +217 -0
- package/lib/arity-table.json +138 -0
- package/lib/arity.js +70 -0
- package/lib/auth.js +118 -112
- package/lib/commands.js +103 -0
- package/lib/config.js +392 -136
- package/lib/design-library/artifact-template.html +500 -0
- package/lib/design-library/assemble-artifact.mjs +21 -0
- package/lib/design-library/build.mjs +226 -0
- package/lib/design-library/cleanup.mjs +45 -0
- package/lib/design-library/index.json +22380 -0
- package/lib/design-library/make-table-data.mjs +39 -0
- package/lib/design-library/query.mjs +104 -0
- package/lib/diagnostics.js +192 -0
- package/lib/edit-engine.js +171 -14
- package/lib/github-agent.mjs +307 -0
- package/lib/history.js +75 -0
- package/lib/mcp.js +258 -0
- package/lib/native-tools.mjs +189 -0
- package/lib/oauth-connectors.js +478 -0
- package/lib/permissions.js +109 -0
- package/lib/plugins.js +353 -0
- package/lib/render-md.mjs +617 -617
- package/lib/server.mjs +457 -0
- package/lib/session-db.js +220 -0
- package/lib/share.js +168 -0
- package/lib/skills.js +80 -22
- package/lib/snapshot.js +146 -0
- package/lib/subagents.js +164 -0
- package/lib/terminal-hints.js +154 -0
- package/lib/textarea.mjs +258 -258
- package/lib/tools.mjs +1658 -79
- package/lib/tui.js +7 -7
- package/lib/tui.mjs +1086 -992
- package/lib/typecheck-worker.js +147 -0
- package/lib/web-tools.js +158 -0
- package/package.json +4 -3
- package/skills/premium-web-design/SKILL.md +154 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-checks single files with the project's own TypeScript, in a separate
|
|
3
|
+
* process so a slow first build never blocks the app. One worker per project
|
|
4
|
+
* root; it keeps a LanguageService warm, so after the first file each check
|
|
5
|
+
* only re-reads what changed.
|
|
6
|
+
*
|
|
7
|
+
* Messages in: { id, file } Messages out: { id, problems: string[] } | { id, error }
|
|
8
|
+
* Started with: argv[2] = project root (folder with tsconfig/jsconfig), argv[3] = typescript module path
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const root = process.argv[2];
|
|
14
|
+
const ts = require(process.argv[3]);
|
|
15
|
+
const MAX = 10;
|
|
16
|
+
|
|
17
|
+
const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json') || ts.findConfigFile(root, ts.sys.fileExists, 'jsconfig.json');
|
|
18
|
+
let options = { allowJs: true, noEmit: true, skipLibCheck: true };
|
|
19
|
+
let fileNames = [];
|
|
20
|
+
if (configPath) {
|
|
21
|
+
const cfg = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
22
|
+
const parsed = ts.parseJsonConfigFileContent(cfg.config || {}, ts.sys, path.dirname(configPath));
|
|
23
|
+
options = { ...parsed.options, noEmit: true };
|
|
24
|
+
fileNames = parsed.fileNames;
|
|
25
|
+
}
|
|
26
|
+
const extra = new Set(); // files checked that the config doesn't list
|
|
27
|
+
|
|
28
|
+
const versions = new Map();
|
|
29
|
+
const version = (f) => {
|
|
30
|
+
try { return String(fs.statSync(f).mtimeMs); } catch { return '0'; }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const host = {
|
|
34
|
+
getScriptFileNames: () => [...fileNames, ...extra],
|
|
35
|
+
getScriptVersion: (f) => version(f),
|
|
36
|
+
getScriptSnapshot: (f) => {
|
|
37
|
+
if (!fs.existsSync(f)) return undefined;
|
|
38
|
+
return ts.ScriptSnapshot.fromString(fs.readFileSync(f, 'utf8'));
|
|
39
|
+
},
|
|
40
|
+
getCurrentDirectory: () => root,
|
|
41
|
+
getCompilationSettings: () => options,
|
|
42
|
+
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
|
|
43
|
+
fileExists: ts.sys.fileExists,
|
|
44
|
+
readFile: ts.sys.readFile,
|
|
45
|
+
readDirectory: ts.sys.readDirectory,
|
|
46
|
+
directoryExists: ts.sys.directoryExists,
|
|
47
|
+
getDirectories: ts.sys.getDirectories,
|
|
48
|
+
};
|
|
49
|
+
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
50
|
+
|
|
51
|
+
function check(file) {
|
|
52
|
+
const abs = path.resolve(file);
|
|
53
|
+
const norm = abs.replace(/\\/g, '/');
|
|
54
|
+
if (!fileNames.some((f) => path.resolve(f) === abs)) extra.add(norm);
|
|
55
|
+
versions.set(abs, version(abs));
|
|
56
|
+
const diags = [...service.getSyntacticDiagnostics(norm), ...service.getSemanticDiagnostics(norm)]
|
|
57
|
+
.filter((d) => d.category === ts.DiagnosticCategory.Error);
|
|
58
|
+
return diags.slice(0, MAX).map((d) => {
|
|
59
|
+
const line = d.file && d.start != null ? d.file.getLineAndCharacterOfPosition(d.start).line + 1 : null;
|
|
60
|
+
return `${line ? `line ${line}: ` : ''}TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Code navigation (the lsp tool): definition, references, hover, symbols ──
|
|
65
|
+
const rel = (f) => path.relative(root, f).replace(/\\/g, '/') || f;
|
|
66
|
+
function place(fileName, start) {
|
|
67
|
+
const sf = service.getProgram().getSourceFile(fileName);
|
|
68
|
+
if (!sf) return `${rel(fileName)}`;
|
|
69
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(start);
|
|
70
|
+
return `${rel(fileName)}:${line + 1}:${character + 1}`;
|
|
71
|
+
}
|
|
72
|
+
function lineText(fileName, start) {
|
|
73
|
+
const sf = service.getProgram().getSourceFile(fileName);
|
|
74
|
+
if (!sf) return '';
|
|
75
|
+
const { line } = sf.getLineAndCharacterOfPosition(start);
|
|
76
|
+
return sf.text.split('\n')[line].trim().slice(0, 160);
|
|
77
|
+
}
|
|
78
|
+
let navFile = null;
|
|
79
|
+
function flattenTree(node, out, depth = 0) {
|
|
80
|
+
for (const c of node.childItems || []) {
|
|
81
|
+
const line = c.spans[0] ? navFile.getLineAndCharacterOfPosition(c.spans[0].start).line + 1 : '?';
|
|
82
|
+
if (c.kind !== 'alias') out.push(`${' '.repeat(depth)}${c.kind} ${c.text} (line ${line})`);
|
|
83
|
+
flattenTree(c, out, depth + 1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function navigate(msg) {
|
|
88
|
+
const abs = path.resolve(msg.file || root);
|
|
89
|
+
const norm = abs.replace(/\\/g, '/');
|
|
90
|
+
if (msg.file && fs.existsSync(abs) && !fileNames.some((f) => path.resolve(f) === abs)) extra.add(norm);
|
|
91
|
+
const op = msg.op;
|
|
92
|
+
if (op === 'workspaceSymbol') {
|
|
93
|
+
const items = (service.getNavigateToItems(msg.query || '', 200) || [])
|
|
94
|
+
.filter((i) => !/[\\/]node_modules[\\/]/.test(i.fileName) && !/[\\/]typescript[\\/]lib[\\/]/.test(i.fileName))
|
|
95
|
+
.slice(0, 40);
|
|
96
|
+
return items.map((i) => `${i.kind} ${i.name} ${place(i.fileName, i.textSpan.start)}`).join('\n') || 'No symbols match.';
|
|
97
|
+
}
|
|
98
|
+
const sf = service.getProgram().getSourceFile(norm);
|
|
99
|
+
if (!sf) return `${msg.file} is not part of a TypeScript/JavaScript project this checker can see.`;
|
|
100
|
+
if (op === 'documentSymbol') {
|
|
101
|
+
navFile = sf;
|
|
102
|
+
const out = [];
|
|
103
|
+
flattenTree(service.getNavigationTree(norm), out);
|
|
104
|
+
return out.join('\n') || 'No symbols in this file.';
|
|
105
|
+
}
|
|
106
|
+
// Position-based: 1-based line/character from the caller.
|
|
107
|
+
const lines = sf.text.split('\n');
|
|
108
|
+
const li = Math.min(Math.max((msg.line || 1) - 1, 0), lines.length - 1);
|
|
109
|
+
let col = msg.character ? msg.character - 1 : -1;
|
|
110
|
+
if (col < 0 && msg.symbol) {
|
|
111
|
+
const at = lines[li].indexOf(msg.symbol);
|
|
112
|
+
col = at >= 0 ? at : -1;
|
|
113
|
+
if (col < 0) {
|
|
114
|
+
const whole = new RegExp(`\\b${msg.symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).exec(sf.text);
|
|
115
|
+
if (whole) return navigateAt(op, norm, whole.index);
|
|
116
|
+
return `Could not find "${msg.symbol}" in ${msg.file}.`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (col < 0) col = Math.max(0, lines[li].search(/\S/));
|
|
120
|
+
const pos = sf.getPositionOfLineAndCharacter(li, col);
|
|
121
|
+
return navigateAt(op, norm, pos);
|
|
122
|
+
}
|
|
123
|
+
function navigateAt(op, norm, pos) {
|
|
124
|
+
if (op === 'hover') {
|
|
125
|
+
const info = service.getQuickInfoAtPosition(norm, pos);
|
|
126
|
+
if (!info) return 'No type information at that position.';
|
|
127
|
+
const sig = ts.displayPartsToString(info.displayParts);
|
|
128
|
+
const doc = ts.displayPartsToString(info.documentation);
|
|
129
|
+
return doc ? `${sig}\n\n${doc}` : sig;
|
|
130
|
+
}
|
|
131
|
+
let locs;
|
|
132
|
+
if (op === 'definition') locs = service.getDefinitionAtPosition(norm, pos) || [];
|
|
133
|
+
else if (op === 'implementation') locs = service.getImplementationAtPosition(norm, pos) || [];
|
|
134
|
+
else if (op === 'references') locs = service.getReferencesAtPosition(norm, pos) || [];
|
|
135
|
+
else return `Unknown operation "${op}".`;
|
|
136
|
+
if (!locs.length) return `No ${op} found at that position.`;
|
|
137
|
+
const shown = locs.slice(0, 40).map((l) => `${place(l.fileName, l.textSpan.start)} ${lineText(l.fileName, l.textSpan.start)}`);
|
|
138
|
+
return `${locs.length} ${op === 'references' ? 'reference' : 'location'}${locs.length === 1 ? '' : 's'}${locs.length > 40 ? ' (first 40)' : ''}:\n${shown.join('\n')}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
process.on('message', (msg) => {
|
|
142
|
+
try {
|
|
143
|
+
if (msg.op) process.send({ id: msg.id, text: navigate(msg) });
|
|
144
|
+
else process.send({ id: msg.id, problems: check(msg.file) });
|
|
145
|
+
} catch (e) { process.send({ id: msg.id, error: e.message }); }
|
|
146
|
+
});
|
|
147
|
+
process.send({ ready: true, hasConfig: !!configPath });
|
package/lib/web-tools.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web access for the agent: fetch a page as readable text, and search the web.
|
|
3
|
+
*
|
|
4
|
+
* Search uses Exa's public MCP endpoint (the same one opencode's websearch tool
|
|
5
|
+
* talks to; no key needed) and falls back to DuckDuckGo's HTML results when
|
|
6
|
+
* Exa is unreachable. Fetching converts HTML to plain markdown-ish text so the
|
|
7
|
+
* model gets the content, not the markup.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 Codeply';
|
|
11
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
12
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
13
|
+
const SEARCH_TIMEOUT_MS = 25000;
|
|
14
|
+
|
|
15
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '-', mdash: '-', hellip: '...', rsquo: "'", lsquo: "'", rdquo: '"', ldquo: '"', copy: '(c)' };
|
|
16
|
+
|
|
17
|
+
function decodeEntities(s) {
|
|
18
|
+
return s
|
|
19
|
+
.replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(Number(n)); } catch { return ''; } })
|
|
20
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, n) => { try { return String.fromCodePoint(parseInt(n, 16)); } catch { return ''; } })
|
|
21
|
+
.replace(/&([a-z]+);/gi, (m, n) => (ENTITIES[n.toLowerCase()] ?? m));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** HTML -> readable text: headings, links, lists, code and paragraphs survive; the rest goes. */
|
|
25
|
+
function htmlToText(html, baseUrl) {
|
|
26
|
+
let s = String(html);
|
|
27
|
+
s = s.replace(/<!--[\s\S]*?-->/g, '');
|
|
28
|
+
s = s.replace(/<(script|style|noscript|template|svg|iframe|head)\b[\s\S]*?<\/\1>/gi, '');
|
|
29
|
+
s = s.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_, code) => '\n```\n' + code.replace(/<[^>]+>/g, '') + '\n```\n');
|
|
30
|
+
s = s.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_, c) => '`' + c.replace(/<[^>]+>/g, '') + '`');
|
|
31
|
+
s = s.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_, n, t) => `\n\n${'#'.repeat(Number(n))} ${t.replace(/<[^>]+>/g, '').trim()}\n\n`);
|
|
32
|
+
s = s.replace(/<a\b[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, t) => {
|
|
33
|
+
const label = t.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
34
|
+
if (!label) return '';
|
|
35
|
+
if (!href || href.startsWith('#') || /^(javascript|mailto):/i.test(href)) return label;
|
|
36
|
+
let abs = href;
|
|
37
|
+
try { abs = new URL(href, baseUrl).href; } catch {}
|
|
38
|
+
return `[${label}](${abs})`;
|
|
39
|
+
});
|
|
40
|
+
s = s.replace(/<li\b[^>]*>/gi, '\n- ');
|
|
41
|
+
s = s.replace(/<br\s*\/?>/gi, '\n');
|
|
42
|
+
s = s.replace(/<\/(p|div|section|article|header|footer|main|nav|ul|ol|table|tr|blockquote|figure)>/gi, '\n');
|
|
43
|
+
s = s.replace(/<\/t[dh]>/gi, ' | ');
|
|
44
|
+
s = s.replace(/<[^>]+>/g, '');
|
|
45
|
+
s = decodeEntities(s);
|
|
46
|
+
s = s.replace(/[ \t\f\v]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n');
|
|
47
|
+
return s.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} url
|
|
52
|
+
* @param {{format?:'text'|'html'|'raw', signal?:AbortSignal, maxChars?:number}} [opts]
|
|
53
|
+
* @returns {Promise<{ok:boolean, text?:string, error?:string, status?:number, contentType?:string, finalUrl?:string}>}
|
|
54
|
+
*/
|
|
55
|
+
async function fetchPage(url, opts = {}) {
|
|
56
|
+
let parsed;
|
|
57
|
+
try { parsed = new URL(url); } catch { return { ok: false, error: `Not a valid URL: ${url}` }; }
|
|
58
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { ok: false, error: 'web_fetch only supports http:// and https:// URLs.' };
|
|
59
|
+
const signals = [AbortSignal.timeout(FETCH_TIMEOUT_MS)];
|
|
60
|
+
if (opts.signal) signals.push(opts.signal);
|
|
61
|
+
let res;
|
|
62
|
+
try {
|
|
63
|
+
res = await fetch(parsed.href, {
|
|
64
|
+
redirect: 'follow',
|
|
65
|
+
signal: AbortSignal.any(signals),
|
|
66
|
+
headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml,application/json,text/plain,text/markdown;q=0.9,*/*;q=0.5', 'Accept-Language': 'en-US,en;q=0.9' },
|
|
67
|
+
});
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { ok: false, error: `Could not reach ${parsed.href}: ${e.name === 'TimeoutError' ? 'timed out' : e.message}` };
|
|
70
|
+
}
|
|
71
|
+
if (!res.ok) return { ok: false, status: res.status, error: `${parsed.href} returned HTTP ${res.status}.` };
|
|
72
|
+
const contentType = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
73
|
+
const declared = Number(res.headers.get('content-length') || 0);
|
|
74
|
+
if (declared > MAX_BYTES) return { ok: false, error: `The page is ${(declared / 1e6).toFixed(1)}MB, over the ${MAX_BYTES / 1e6}MB limit.` };
|
|
75
|
+
if (/^(image|video|audio)\//.test(contentType) || /^application\/(zip|octet-stream|pdf|gzip)/.test(contentType)) {
|
|
76
|
+
return { ok: false, contentType, error: `${parsed.href} is ${contentType}, not text. Use fetch_image for images or run curl to download a file.` };
|
|
77
|
+
}
|
|
78
|
+
let buf;
|
|
79
|
+
try { buf = Buffer.from(await res.arrayBuffer()); } catch (e) { return { ok: false, error: `Failed reading ${parsed.href}: ${e.message}` }; }
|
|
80
|
+
if (buf.length > MAX_BYTES) return { ok: false, error: `The page is over the ${MAX_BYTES / 1e6}MB limit.` };
|
|
81
|
+
const body = buf.toString('utf8');
|
|
82
|
+
const format = opts.format || 'text';
|
|
83
|
+
const isHtml = contentType.includes('html') || (!contentType && /^\s*<(!doctype|html)/i.test(body));
|
|
84
|
+
let text = body;
|
|
85
|
+
if (isHtml && format === 'text') text = htmlToText(body, res.url || parsed.href);
|
|
86
|
+
else if (contentType.includes('json') && format !== 'raw') {
|
|
87
|
+
try { text = JSON.stringify(JSON.parse(body), null, 2); } catch {}
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, text, contentType, finalUrl: res.url || parsed.href, status: res.status };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Search ──────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async function exaSearch(query, num, signal) {
|
|
95
|
+
const signals = [AbortSignal.timeout(SEARCH_TIMEOUT_MS)];
|
|
96
|
+
if (signal) signals.push(signal);
|
|
97
|
+
const res = await fetch('https://mcp.exa.ai/mcp', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
signal: AbortSignal.any(signals),
|
|
100
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
|
103
|
+
params: { name: 'web_search_exa', arguments: { query, numResults: num, type: 'auto', livecrawl: 'fallback' } },
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) throw new Error(`Exa returned HTTP ${res.status}`);
|
|
107
|
+
const raw = await res.text();
|
|
108
|
+
// The reply is either plain JSON or an SSE stream of "data: {...}" lines.
|
|
109
|
+
const payloads = [];
|
|
110
|
+
if (raw.trimStart().startsWith('{')) payloads.push(raw);
|
|
111
|
+
else for (const line of raw.split('\n')) if (line.startsWith('data:')) payloads.push(line.slice(5).trim());
|
|
112
|
+
for (const p of payloads) {
|
|
113
|
+
let j;
|
|
114
|
+
try { j = JSON.parse(p); } catch { continue; }
|
|
115
|
+
if (j.error) throw new Error(j.error.message || 'Exa error');
|
|
116
|
+
const text = (j.result?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
|
|
117
|
+
if (text) return text;
|
|
118
|
+
}
|
|
119
|
+
throw new Error('Exa returned no results');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function ddgSearch(query, num, signal) {
|
|
123
|
+
const signals = [AbortSignal.timeout(SEARCH_TIMEOUT_MS)];
|
|
124
|
+
if (signal) signals.push(signal);
|
|
125
|
+
const res = await fetch('https://html.duckduckgo.com/html/', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
signal: AbortSignal.any(signals),
|
|
128
|
+
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
129
|
+
body: `q=${encodeURIComponent(query)}`,
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok) throw new Error(`DuckDuckGo returned HTTP ${res.status}`);
|
|
132
|
+
const html = await res.text();
|
|
133
|
+
const out = [];
|
|
134
|
+
const re = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>)?/gi;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = re.exec(html)) && out.length < num) {
|
|
137
|
+
let href = decodeEntities(m[1]);
|
|
138
|
+
const u = /[?&]uddg=([^&]+)/.exec(href);
|
|
139
|
+
if (u) { try { href = decodeURIComponent(u[1]); } catch {} }
|
|
140
|
+
const title = decodeEntities(m[2].replace(/<[^>]+>/g, '')).trim();
|
|
141
|
+
const snippet = decodeEntities((m[3] || '').replace(/<[^>]+>/g, '')).trim();
|
|
142
|
+
if (title && /^https?:/.test(href)) out.push(`Title: ${title}\nURL: ${href}${snippet ? `\n${snippet}` : ''}`);
|
|
143
|
+
}
|
|
144
|
+
if (!out.length) throw new Error('DuckDuckGo returned no results');
|
|
145
|
+
return out.join('\n\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @returns {Promise<{ok:boolean, text?:string, error?:string, via?:string}>} */
|
|
149
|
+
async function webSearch(query, opts = {}) {
|
|
150
|
+
const num = Math.min(Math.max(Number(opts.num) || 8, 1), 20);
|
|
151
|
+
const errors = [];
|
|
152
|
+
try { return { ok: true, text: await exaSearch(query, num, opts.signal), via: 'exa' }; } catch (e) { errors.push(e.message); }
|
|
153
|
+
if (opts.signal?.aborted) return { ok: false, error: 'Search cancelled.' };
|
|
154
|
+
try { return { ok: true, text: await ddgSearch(query, num, opts.signal), via: 'duckduckgo' }; } catch (e) { errors.push(e.message); }
|
|
155
|
+
return { ok: false, error: `Web search failed (${errors.join('; ')}).` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { fetchPage, webSearch, htmlToText };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeply-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Codeply from the terminal
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Codeply from the terminal: an interactive AI coding agent, a local server (codeply serve), and a GitHub agent that runs in your own Actions runner.",
|
|
5
5
|
"main": "bin/codeply.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"codeply": "bin/codeply.js"
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
11
|
"lib",
|
|
12
|
-
"skills"
|
|
12
|
+
"skills",
|
|
13
|
+
"THIRD_PARTY_NOTICES.md"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=18"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: premium-web-design
|
|
3
|
+
description: Codeply's default visual bar for any website, landing page, or UI it generates — vanilla HTML/CSS/JS techniques (type, color, depth, scroll-triggered motion, subtle 3D) that make even a trivial brief (a tea shop, a local plumber, a one-page portfolio) read as an expensive, art-directed build instead of a templated AI scaffold. Use for every "build me a website / page / landing site" request, no matter how small the business sounds — small brief is not license for a flat result.
|
|
4
|
+
metadata:
|
|
5
|
+
origin: codeply
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Premium Web Design
|
|
9
|
+
|
|
10
|
+
The gap between "a website" and "a website that looks like it cost $10k" is not
|
|
11
|
+
more content. It is five things done deliberately instead of by default: type,
|
|
12
|
+
color, depth, motion, and restraint. This skill is the checklist that turns a
|
|
13
|
+
plain-HTML brief — a tea shop, a barber, a two-person agency — into something
|
|
14
|
+
that reads as art-directed. Apply it **even when the user's request is one
|
|
15
|
+
sentence and sounds trivial.** The size of the brief is not license for a flat
|
|
16
|
+
result; a tea shop site gets the same craft pass as a SaaS landing page.
|
|
17
|
+
|
|
18
|
+
## When to Use
|
|
19
|
+
|
|
20
|
+
Any time you are about to write or restyle HTML/CSS for a page a human will
|
|
21
|
+
look at: landing pages, marketing sites, portfolios, small-business sites,
|
|
22
|
+
dashboards, docs, artifacts. If the deliverable renders in a browser and has
|
|
23
|
+
no explicit "keep it minimal/utilitarian" instruction from the user, this
|
|
24
|
+
skill applies by default — do not wait to be asked for "premium" or
|
|
25
|
+
"animated."
|
|
26
|
+
|
|
27
|
+
## The Five Levers
|
|
28
|
+
|
|
29
|
+
### 1. Type — pick a pairing, not a default
|
|
30
|
+
|
|
31
|
+
Never ship `system-ui` / Arial as the whole identity. Pick ONE deliberate
|
|
32
|
+
pairing and commit:
|
|
33
|
+
- Editorial: a serif display (Fraunces, Playfair Display, Instrument Serif) +
|
|
34
|
+
a clean grotesk body (Inter, General Sans, Geist).
|
|
35
|
+
- Technical/modern: one variable grotesk (Inter, Geist, Söhne-alike) at two
|
|
36
|
+
weights, huge size contrast between hero and body.
|
|
37
|
+
- Warm/local business (cafés, shops, studios): a humanist serif or friendly
|
|
38
|
+
display headline + a quiet sans body — avoid corporate SaaS grotesk here,
|
|
39
|
+
it reads generic for a tea shop.
|
|
40
|
+
|
|
41
|
+
Rules: headline sizes should feel oversized relative to instinct (clamp with
|
|
42
|
+
`clamp(2.5rem, 6vw, 5.5rem)`), line-height tight on display type (1.0–1.1),
|
|
43
|
+
loose on body (1.5–1.7), and letter-spacing slightly negative on large type
|
|
44
|
+
(`-0.02em` to `-0.04em`). Import via `@font-face`/Google Fonts `<link>` —
|
|
45
|
+
never leave it at the browser default.
|
|
46
|
+
|
|
47
|
+
### 2. Color — a system, not a palette guess
|
|
48
|
+
|
|
49
|
+
Ban: purple-to-blue gradient hero, pure `#000`/`#fff` with a single accent
|
|
50
|
+
blue, "AI slop" default (indigo #6366f1 / violet #8b5cf6 on white cards).
|
|
51
|
+
Instead build a small token system from the domain:
|
|
52
|
+
- 1 base neutral (often warm, not pure gray — `#f7f4ee`, `#111014`)
|
|
53
|
+
- 1 ink color for text (rarely pure black — try a near-black with a hue lean)
|
|
54
|
+
- 1–2 brand colors pulled from the actual subject (a tea shop: terracotta,
|
|
55
|
+
moss, cream, not tech-blue)
|
|
56
|
+
- 1 accent used sparingly (links, one CTA, one highlight) — not on every card
|
|
57
|
+
|
|
58
|
+
Define them as CSS custom properties on `:root` and reuse everywhere; that
|
|
59
|
+
alone makes a page look designed instead of assembled.
|
|
60
|
+
|
|
61
|
+
### 3. Depth — layered, not flat-card-grid
|
|
62
|
+
|
|
63
|
+
Avoid the default "grid of identical white cards with a drop-shadow" — it is
|
|
64
|
+
the single most recognizable AI-generated-site tell. Instead:
|
|
65
|
+
- Vary section backgrounds (alternate base/ink/tinted sections) so the page
|
|
66
|
+
has rhythm when you scroll, not one long white column.
|
|
67
|
+
- Use large background shapes, subtle grain/noise texture, or a soft radial
|
|
68
|
+
glow behind hero content instead of a flat solid fill.
|
|
69
|
+
- Layer real photography or generated imagery behind translucent panels
|
|
70
|
+
(`backdrop-filter: blur(16px)`, semi-opaque background) for a glass feel
|
|
71
|
+
where it fits the brand — skip it for utilitarian dashboards.
|
|
72
|
+
- Give the hero one asymmetric or oversized element (an oversized product
|
|
73
|
+
shot bleeding off-canvas, an angled image, a big pull-quote) instead of
|
|
74
|
+
centered text + centered image every time.
|
|
75
|
+
|
|
76
|
+
**Imagery must be on-topic, not just present.** A hero image is worse than no
|
|
77
|
+
image if it's unrelated to the subject — a beach photo on a tea brand's
|
|
78
|
+
"Our Heritage" section reads as more broken than a placeholder color block,
|
|
79
|
+
because it actively contradicts the copy next to it. When fetching stock
|
|
80
|
+
imagery with `fetch_image`, use a keyword-searchable source
|
|
81
|
+
(`https://loremflickr.com/<w>/<h>/<keyword1>,<keyword2>`) and pick keywords
|
|
82
|
+
per-section from that section's actual subject — never a fully-random source
|
|
83
|
+
like picsum.photos on a themed site. A tea shop's hero gets `tea,leaves` or
|
|
84
|
+
`teapot,ceremony`, a product card for oolong gets `oolong,tea`, not one
|
|
85
|
+
generic keyword reused everywhere or no keyword at all. If no source can be
|
|
86
|
+
made reliably on-topic for a given spot (an odd or very specific subject),
|
|
87
|
+
prefer a solid-color/gradient placeholder panel over a wrong photo, and say
|
|
88
|
+
so to the user.
|
|
89
|
+
|
|
90
|
+
### 4. Motion — scroll-triggered, physical, restrained
|
|
91
|
+
|
|
92
|
+
A static page reads cheap even with good type/color. Add motion with plain
|
|
93
|
+
CSS/JS (no framework required):
|
|
94
|
+
|
|
95
|
+
```css
|
|
96
|
+
.reveal { opacity: 0; transform: translateY(24px); transition: opacity .7s cubic-bezier(.16,1,.3,1), transform .7s cubic-bezier(.16,1,.3,1); }
|
|
97
|
+
.reveal.is-visible { opacity: 1; transform: translateY(0); }
|
|
98
|
+
@media (prefers-reduced-motion: reduce) { .reveal { transition: none; opacity: 1; transform: none; } }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
const io = new IntersectionObserver((entries) => {
|
|
103
|
+
for (const e of entries) if (e.isIntersecting) e.target.classList.add('is-visible');
|
|
104
|
+
}, { threshold: 0.15 });
|
|
105
|
+
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Layer in: a subtle parallax on the hero image (`transform: translateY()`
|
|
109
|
+
tied to scroll position, small range like 20–40px), a hover tilt on cards
|
|
110
|
+
(`transform: perspective(800px) rotateX() rotateY()` from pointer position,
|
|
111
|
+
a cheap but convincing "3D" feel), and a smooth `scroll-behavior: smooth` for
|
|
112
|
+
anchor nav. Stagger reveal delays across siblings (`transition-delay` +
|
|
113
|
+
`nth-child`) instead of everything fading in at once — staggering is what
|
|
114
|
+
makes a section feel choreographed rather than triggered.
|
|
115
|
+
|
|
116
|
+
Always respect `prefers-reduced-motion`. Keep transitions 200–700ms;
|
|
117
|
+
snappy for hover/press, slower for scroll reveals.
|
|
118
|
+
|
|
119
|
+
### 5. Restraint — the difference between premium and busy
|
|
120
|
+
|
|
121
|
+
- One hero move, one card-hover move, one scroll-reveal pattern — reused
|
|
122
|
+
consistently, not five different animation styles competing.
|
|
123
|
+
`frontend-design-direction` has the anti-pattern list (no cards-in-cards,
|
|
124
|
+
no gradient blobs, no vague hero copy) — use_skill it too on larger builds.
|
|
125
|
+
- Whitespace is a design choice, not leftover space: generous section
|
|
126
|
+
padding (`clamp(4rem, 10vw, 8rem)` vertical), generous gaps in grids.
|
|
127
|
+
Cramped sections are the second most common AI-slop tell after flat cards.
|
|
128
|
+
- Real, specific copy beats generic copy: name the actual product (single-
|
|
129
|
+
origin oolong, not "our amazing products"), and if there's no CMS/content
|
|
130
|
+
yet, write copy that sounds like this specific business, not a template.
|
|
131
|
+
|
|
132
|
+
## Quick Pre-Ship Checklist
|
|
133
|
+
|
|
134
|
+
- Is there a real font pairing, or did I leave system defaults?
|
|
135
|
+
- Is the palette pulled from the subject, or is it default-AI indigo/violet?
|
|
136
|
+
- Does the hero have one asymmetric/oversized visual idea, or is everything
|
|
137
|
+
centered and evenly sized?
|
|
138
|
+
- Does anything move on scroll or hover, or is the page fully static?
|
|
139
|
+
- Are sections visually differentiated (background/rhythm), or one flat
|
|
140
|
+
white column of same-size cards?
|
|
141
|
+
- Would this specific business (a tea shop, not "a company") recognize
|
|
142
|
+
itself in the copy and color choices?
|
|
143
|
+
|
|
144
|
+
If any answer is "no," the build is not done — go back and apply the lever
|
|
145
|
+
before calling it finished.
|
|
146
|
+
|
|
147
|
+
## Related Skills
|
|
148
|
+
|
|
149
|
+
- `frontend-design-direction` — direction-setting discipline (purpose,
|
|
150
|
+
audience, tone, anti-patterns) for larger or more product-y builds.
|
|
151
|
+
- `motion-ui`, `motion-foundations`, `motion-patterns` — deeper motion system
|
|
152
|
+
for React/Next.js projects specifically (this skill's motion section is the
|
|
153
|
+
framework-free equivalent for plain HTML/CSS/JS builds).
|
|
154
|
+
- `frontend-a11y` — accessibility pass once the visual direction is set.
|