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.
Files changed (43) hide show
  1. package/THIRD_PARTY_NOTICES.md +63 -0
  2. package/bin/codeply.js +558 -370
  3. package/lib/agent.mjs +1627 -160
  4. package/lib/ai.js +1107 -387
  5. package/lib/apply-limit.js +33 -51
  6. package/lib/apply-patch.js +217 -0
  7. package/lib/arity-table.json +138 -0
  8. package/lib/arity.js +70 -0
  9. package/lib/auth.js +118 -112
  10. package/lib/commands.js +103 -0
  11. package/lib/config.js +392 -136
  12. package/lib/design-library/artifact-template.html +500 -0
  13. package/lib/design-library/assemble-artifact.mjs +21 -0
  14. package/lib/design-library/build.mjs +226 -0
  15. package/lib/design-library/cleanup.mjs +45 -0
  16. package/lib/design-library/index.json +22380 -0
  17. package/lib/design-library/make-table-data.mjs +39 -0
  18. package/lib/design-library/query.mjs +104 -0
  19. package/lib/diagnostics.js +192 -0
  20. package/lib/edit-engine.js +171 -14
  21. package/lib/github-agent.mjs +307 -0
  22. package/lib/history.js +75 -0
  23. package/lib/mcp.js +258 -0
  24. package/lib/native-tools.mjs +189 -0
  25. package/lib/oauth-connectors.js +478 -0
  26. package/lib/permissions.js +109 -0
  27. package/lib/plugins.js +353 -0
  28. package/lib/render-md.mjs +617 -617
  29. package/lib/server.mjs +457 -0
  30. package/lib/session-db.js +220 -0
  31. package/lib/share.js +168 -0
  32. package/lib/skills.js +80 -22
  33. package/lib/snapshot.js +146 -0
  34. package/lib/subagents.js +164 -0
  35. package/lib/terminal-hints.js +154 -0
  36. package/lib/textarea.mjs +258 -258
  37. package/lib/tools.mjs +1658 -79
  38. package/lib/tui.js +7 -7
  39. package/lib/tui.mjs +1086 -992
  40. package/lib/typecheck-worker.js +147 -0
  41. package/lib/web-tools.js +158 -0
  42. package/package.json +4 -3
  43. package/skills/premium-web-design/SKILL.md +154 -0
package/lib/plugins.js ADDED
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Plugins: a folder (usually a git repo) that bundles things Craft already
3
+ * knows how to load, so people can share a whole setup with one command.
4
+ *
5
+ * my-plugin/
6
+ * codeply-plugin.json { "name", "version", "description", "author", "homepage", "instructions": ["instructions.md"] }
7
+ * commands/*.md slash commands, run as /my-plugin:name
8
+ * skills/<n>/SKILL.md skills the agent can load
9
+ * mcp.json { "mcpServers": { ... } } (also .mcp.json)
10
+ * instructions.md rules added to the agent's prompt
11
+ *
12
+ * Installed to ~/.codeply/plugins/<name> (every project) or
13
+ * <project>/.codeply/plugins/<name> (this project; same name wins).
14
+ * Plugins hold no executable hooks of their own: MCP servers are the only
15
+ * thing that can start a process, and install shows their exact commands and
16
+ * asks first. `.claude-plugin/plugin.json` and `.mcp.json` are read too, so
17
+ * plugins written in that layout install as they are.
18
+ */
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { execFile } = require('child_process');
23
+
24
+ const MANIFEST_FILES = ['codeply-plugin.json', 'plugin.json', path.join('.claude-plugin', 'plugin.json')];
25
+ const RECORD_FILE = '.codeply-install.json';
26
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
27
+ const MAX_INSTRUCTION_CHARS = 6000;
28
+ const SKIP_COPY = new Set(['.git', 'node_modules']);
29
+
30
+ const userRoot = () => path.join(os.homedir(), '.codeply', 'plugins');
31
+ const projectRoot = (cwd) => path.join(cwd, '.codeply', 'plugins');
32
+ const stateFile = () => path.join(os.homedir(), '.codeply', 'plugins.json');
33
+
34
+ function readJson(file) {
35
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
36
+ }
37
+
38
+ function readState() {
39
+ const s = readJson(stateFile());
40
+ return { disabled: Array.isArray(s && s.disabled) ? s.disabled : [] };
41
+ }
42
+
43
+ function writeState(state) {
44
+ fs.mkdirSync(path.dirname(stateFile()), { recursive: true });
45
+ fs.writeFileSync(stateFile(), JSON.stringify(state, null, 2));
46
+ }
47
+
48
+ const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
49
+
50
+ /** Inside `root` after resolving? Guards manifest paths against ../ escapes. */
51
+ function inside(root, rel) {
52
+ const full = path.resolve(root, rel);
53
+ return full === path.resolve(root) || full.startsWith(path.resolve(root) + path.sep) ? full : null;
54
+ }
55
+
56
+ /** Read a plugin folder; { error } if it is not one. */
57
+ function readPlugin(dir, fallbackName) {
58
+ let manifest = null;
59
+ for (const f of MANIFEST_FILES) {
60
+ manifest = readJson(path.join(dir, f));
61
+ if (manifest && typeof manifest === 'object') break;
62
+ manifest = null;
63
+ }
64
+ const name = slug((manifest && manifest.name) || fallbackName || path.basename(dir));
65
+ if (!NAME_RE.test(name)) return { error: `"${name}" is not a valid plugin name (lowercase letters, digits, dashes).` };
66
+
67
+ const has = (p) => { try { return fs.statSync(path.join(dir, p)).isDirectory(); } catch { return false; } };
68
+ const commandsDir = has('commands') ? path.join(dir, 'commands') : null;
69
+ const skillsDir = has('skills') ? path.join(dir, 'skills') : null;
70
+ const mcpFile = ['mcp.json', '.mcp.json'].map((f) => path.join(dir, f)).find((f) => fs.existsSync(f)) || null;
71
+
72
+ const wanted = Array.isArray(manifest && manifest.instructions) ? manifest.instructions
73
+ : typeof (manifest && manifest.instructions) === 'string' ? [manifest.instructions]
74
+ : ['instructions.md'];
75
+ const instructionFiles = wanted
76
+ .map((rel) => inside(dir, String(rel)))
77
+ .filter((f) => f && fs.existsSync(f) && fs.statSync(f).isFile());
78
+
79
+ let mcpServers = {};
80
+ if (mcpFile) {
81
+ const j = readJson(mcpFile);
82
+ mcpServers = (j && (j.mcpServers || j.servers)) || {};
83
+ }
84
+ return {
85
+ name,
86
+ version: String((manifest && manifest.version) || ''),
87
+ description: String((manifest && manifest.description) || ''),
88
+ author: typeof (manifest && manifest.author) === 'object' && manifest.author ? String(manifest.author.name || '') : String((manifest && manifest.author) || ''),
89
+ homepage: String((manifest && manifest.homepage) || ''),
90
+ dir, commandsDir, skillsDir, mcpFile, mcpServers, instructionFiles,
91
+ hasManifest: !!manifest,
92
+ };
93
+ }
94
+
95
+ function countFiles(dir, test) {
96
+ let n = 0;
97
+ const walk = (d) => {
98
+ let entries = [];
99
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
100
+ for (const e of entries) {
101
+ if (e.isDirectory()) walk(path.join(d, e.name));
102
+ else if (e.isFile() && test(e.name)) n++;
103
+ }
104
+ };
105
+ walk(dir);
106
+ return n;
107
+ }
108
+
109
+ /** What a plugin contains, in a shape the installer can show before asking. */
110
+ function summarize(p) {
111
+ return {
112
+ name: p.name, version: p.version, description: p.description, author: p.author, homepage: p.homepage,
113
+ commands: p.commandsDir ? countFiles(p.commandsDir, (n) => n.toLowerCase().endsWith('.md')) : 0,
114
+ skills: p.skillsDir ? fs.readdirSync(p.skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && fs.existsSync(path.join(p.skillsDir, e.name, 'SKILL.md'))).length : 0,
115
+ instructions: p.instructionFiles.length,
116
+ mcpServers: Object.entries(p.mcpServers).map(([n, s]) => ({
117
+ name: n,
118
+ runs: s && s.command ? [s.command, ...(s.args || [])].join(' ') : '',
119
+ url: s && s.url ? String(s.url) : '',
120
+ })),
121
+ };
122
+ }
123
+
124
+ function scanRoot(root, scope, out) {
125
+ let entries = [];
126
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
127
+ for (const e of entries) {
128
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
129
+ const p = readPlugin(path.join(root, e.name), e.name);
130
+ if (p.error) continue;
131
+ const record = readJson(path.join(root, e.name, RECORD_FILE)) || {};
132
+ out.set(p.name, { ...p, scope, source: record.source || '', commit: record.commit || '' });
133
+ }
134
+ }
135
+
136
+ /** Installed plugins for a project (project ones override user ones). */
137
+ function listPlugins(cwd, { includeDisabled = true } = {}) {
138
+ const found = new Map();
139
+ scanRoot(userRoot(), 'user', found);
140
+ if (cwd) scanRoot(projectRoot(cwd), 'project', found);
141
+ const { disabled } = readState();
142
+ return [...found.values()]
143
+ .map((p) => ({ ...p, enabled: !disabled.includes(p.name) }))
144
+ .filter((p) => includeDisabled || p.enabled)
145
+ .sort((a, b) => a.name.localeCompare(b.name));
146
+ }
147
+
148
+ // ─── What the rest of the engine reads ──────────────────────────────────────
149
+
150
+ const active = (cwd) => listPlugins(cwd, { includeDisabled: false });
151
+
152
+ function commandDirs(cwd) {
153
+ return active(cwd).filter((p) => p.commandsDir).map((p) => ({ plugin: p.name, dir: p.commandsDir }));
154
+ }
155
+
156
+ function skillDirs(cwd) {
157
+ return active(cwd).filter((p) => p.skillsDir).map((p) => ({ plugin: p.name, dir: p.skillsDir }));
158
+ }
159
+
160
+ function subst(value, dir) {
161
+ return typeof value === 'string'
162
+ ? value.replace(/\$\{(?:CODEPLY_PLUGIN_ROOT|CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT)\}/g, dir)
163
+ : Array.isArray(value) ? value.map((v) => subst(v, dir))
164
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, subst(v, dir)]))
165
+ : value;
166
+ }
167
+
168
+ /** MCP servers from plugins as `<plugin>-<server>`, with ${CODEPLY_PLUGIN_ROOT} filled in. */
169
+ function mcpServers(cwd) {
170
+ const out = {};
171
+ for (const p of active(cwd)) {
172
+ for (const [n, spec] of Object.entries(p.mcpServers)) {
173
+ if (spec && typeof spec === 'object') out[`${p.name}-${slug(n)}`] = subst(spec, p.dir);
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /** Prompt blocks: { plugin, file, text } for each enabled plugin instruction file. */
180
+ function instructionBlocks(cwd) {
181
+ const out = [];
182
+ for (const p of active(cwd)) {
183
+ for (const file of p.instructionFiles) {
184
+ try {
185
+ let text = fs.readFileSync(file, 'utf8').trim();
186
+ if (!text) continue;
187
+ if (text.length > MAX_INSTRUCTION_CHARS) text = `${text.slice(0, MAX_INSTRUCTION_CHARS)}\n[... truncated, read ${file} for the rest]`;
188
+ out.push({ plugin: p.name, file, text });
189
+ } catch {}
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+
195
+ // ─── Installing ─────────────────────────────────────────────────────────────
196
+
197
+ function git(args, opts = {}) {
198
+ return new Promise((resolve, reject) => {
199
+ execFile('git', args, { timeout: 120000, windowsHide: true, maxBuffer: 8 * 1024 * 1024, ...opts }, (err, stdout, stderr) => {
200
+ if (err) reject(new Error((stderr || err.message).toString().trim().split('\n').slice(-3).join(' ')));
201
+ else resolve(String(stdout).trim());
202
+ });
203
+ });
204
+ }
205
+
206
+ /** owner/repo, github:owner/repo, a git URL, or a local folder (each optionally #ref). */
207
+ function parseSource(source) {
208
+ let src = String(source || '').trim();
209
+ let ref = '';
210
+ const hash = src.lastIndexOf('#');
211
+ if (hash > 0) { ref = src.slice(hash + 1); src = src.slice(0, hash); }
212
+ if (ref && !/^[A-Za-z0-9._\/-]+$/.test(ref)) return { error: 'That #ref has characters a branch or tag cannot have.' };
213
+ if (!src) return { error: 'No source given.' };
214
+ if (fs.existsSync(src)) return { kind: 'local', path: path.resolve(src), ref: '' };
215
+ src = src.replace(/^github:/i, '');
216
+ if (/^[\w.-]+\/[\w.-]+$/.test(src)) return { kind: 'git', url: `https://github.com/${src.replace(/\.git$/, '')}.git`, ref };
217
+ if (/^(https:\/\/|git@[\w.-]+:|ssh:\/\/)/.test(src)) return { kind: 'git', url: src, ref };
218
+ if (/^http:\/\//.test(src)) return { error: 'Use https:// for a git source.' };
219
+ return { error: `"${src}" is not a folder, an owner/repo, or a git URL.` };
220
+ }
221
+
222
+ function copyTree(src, dest) {
223
+ fs.mkdirSync(dest, { recursive: true });
224
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
225
+ if (SKIP_COPY.has(e.name) || e.isSymbolicLink()) continue;
226
+ const s = path.join(src, e.name);
227
+ const d = path.join(dest, e.name);
228
+ if (e.isDirectory()) copyTree(s, d);
229
+ else if (e.isFile()) fs.copyFileSync(s, d);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Step one of an install: fetch to a temp folder and read it. Nothing is
235
+ * installed yet; the caller shows `summary` and then calls finishInstall().
236
+ */
237
+ async function prepareInstall(source) {
238
+ const src = parseSource(source);
239
+ if (src.error) return { ok: false, error: src.error };
240
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-plugin-'));
241
+ const work = path.join(tmp, 'p');
242
+ try {
243
+ let commit = '';
244
+ if (src.kind === 'local') {
245
+ copyTree(src.path, work);
246
+ } else {
247
+ const args = ['-c', 'core.symlinks=false', 'clone', '--depth', '1', '--no-tags'];
248
+ if (src.ref) args.push('--branch', src.ref);
249
+ args.push('--', src.url, work);
250
+ await git(args, { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
251
+ commit = await git(['-C', work, 'rev-parse', 'HEAD']).catch(() => '');
252
+ fs.rmSync(path.join(work, '.git'), { recursive: true, force: true });
253
+ }
254
+ const p = readPlugin(work, src.kind === 'local' ? path.basename(src.path) : path.basename(src.url).replace(/\.git$/, ''));
255
+ if (p.error) throw new Error(p.error);
256
+ const summary = summarize(p);
257
+ if (!summary.commands && !summary.skills && !summary.instructions && !summary.mcpServers.length) {
258
+ throw new Error('Nothing to install: no commands/, skills/, mcp.json or instructions.md in it.');
259
+ }
260
+ return { ok: true, tmp, work, name: p.name, summary, record: { source: String(source).trim(), commit } };
261
+ } catch (e) {
262
+ fs.rmSync(tmp, { recursive: true, force: true });
263
+ return { ok: false, error: e.message };
264
+ }
265
+ }
266
+
267
+ function discardInstall(prepared) {
268
+ if (prepared && prepared.tmp) fs.rmSync(prepared.tmp, { recursive: true, force: true });
269
+ }
270
+
271
+ /** Step two: move a prepared plugin into place. */
272
+ function finishInstall(prepared, { scope = 'user', cwd, force = false } = {}) {
273
+ try {
274
+ if (scope === 'project' && !cwd) return { ok: false, error: 'A project install needs a project folder.' };
275
+ const root = scope === 'project' ? projectRoot(cwd) : userRoot();
276
+ const dest = path.join(root, prepared.name);
277
+ if (fs.existsSync(dest) && !force) return { ok: false, error: `"${prepared.name}" is already installed. Use update, or install again with force.` };
278
+ fs.mkdirSync(root, { recursive: true });
279
+ fs.rmSync(dest, { recursive: true, force: true });
280
+ copyTree(prepared.work, dest);
281
+ fs.writeFileSync(path.join(dest, RECORD_FILE), JSON.stringify({ ...prepared.record, scope, installedAt: new Date().toISOString() }, null, 2));
282
+ return { ok: true, name: prepared.name, dest, summary: prepared.summary };
283
+ } finally {
284
+ discardInstall(prepared);
285
+ }
286
+ }
287
+
288
+ /** Prepare + finish in one go, for callers that already have consent. */
289
+ async function installPlugin(source, opts = {}) {
290
+ const prepared = await prepareInstall(source);
291
+ if (!prepared.ok) return prepared;
292
+ return finishInstall(prepared, opts);
293
+ }
294
+
295
+ function findInstalled(name, cwd) {
296
+ return listPlugins(cwd).find((p) => p.name === slug(name));
297
+ }
298
+
299
+ function removePlugin(name, cwd) {
300
+ const p = findInstalled(name, cwd);
301
+ if (!p) return { ok: false, error: `No plugin named "${name}".` };
302
+ fs.rmSync(p.dir, { recursive: true, force: true });
303
+ const state = readState();
304
+ if (state.disabled.includes(p.name)) writeState({ disabled: state.disabled.filter((n) => n !== p.name) });
305
+ return { ok: true, name: p.name };
306
+ }
307
+
308
+ function setEnabled(name, enabled, cwd) {
309
+ const p = findInstalled(name, cwd);
310
+ if (!p) return { ok: false, error: `No plugin named "${name}".` };
311
+ const state = readState();
312
+ const rest = state.disabled.filter((n) => n !== p.name);
313
+ writeState({ disabled: enabled ? rest : [...rest, p.name] });
314
+ return { ok: true, name: p.name, enabled: !!enabled };
315
+ }
316
+
317
+ /** Re-fetch from where it was installed. Returns the prepared install so the caller can show what changed. */
318
+ async function prepareUpdate(name, cwd) {
319
+ const p = findInstalled(name, cwd);
320
+ if (!p) return { ok: false, error: `No plugin named "${name}".` };
321
+ if (!p.source) return { ok: false, error: `"${p.name}" has no recorded source to update from.` };
322
+ const prepared = await prepareInstall(p.source);
323
+ if (!prepared.ok) return prepared;
324
+ if (prepared.name !== p.name) { discardInstall(prepared); return { ok: false, error: `The source now calls itself "${prepared.name}", not "${p.name}". Remove and install it again.` }; }
325
+ return { ...prepared, scope: p.scope, before: summarize(p) };
326
+ }
327
+
328
+ /** Scaffold a new plugin folder ready to push to GitHub. */
329
+ function scaffoldPlugin(name, parentDir) {
330
+ const n = slug(name);
331
+ if (!NAME_RE.test(n)) return { ok: false, error: 'Use lowercase letters, digits and dashes for the name.' };
332
+ const dir = path.join(parentDir, n);
333
+ if (fs.existsSync(dir)) return { ok: false, error: `${dir} already exists.` };
334
+ fs.mkdirSync(path.join(dir, 'commands'), { recursive: true });
335
+ fs.mkdirSync(path.join(dir, 'skills', 'example'), { recursive: true });
336
+ fs.writeFileSync(path.join(dir, 'codeply-plugin.json'), JSON.stringify({
337
+ name: n, version: '0.1.0', description: 'What this plugin adds to Craft', author: '', homepage: '',
338
+ instructions: ['instructions.md'],
339
+ }, null, 2) + '\n');
340
+ fs.writeFileSync(path.join(dir, 'commands', 'hello.md'), `---\ndescription: Say hello (example command, run it as /${n}:hello)\n---\nGreet the user and tell them which project this is. $ARGUMENTS\n`);
341
+ fs.writeFileSync(path.join(dir, 'skills', 'example', 'SKILL.md'), `---\nname: ${n}-example\ndescription: Replace with when the agent should load this skill\n---\nStep by step instructions for the agent go here.\n`);
342
+ fs.writeFileSync(path.join(dir, 'instructions.md'), `Rules the agent follows in every project where ${n} is installed. Delete this file if you have none.\n`);
343
+ fs.writeFileSync(path.join(dir, 'mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2) + '\n');
344
+ fs.writeFileSync(path.join(dir, 'README.md'), `# ${n}\n\nInstall: \`codeply plugin install <owner>/${n}\`\n\nPush this folder to GitHub and anyone can install it with that command.\n`);
345
+ return { ok: true, dir, name: n };
346
+ }
347
+
348
+ module.exports = {
349
+ listPlugins, readPlugin, summarize, parseSource,
350
+ commandDirs, skillDirs, mcpServers, instructionBlocks,
351
+ prepareInstall, finishInstall, discardInstall, installPlugin,
352
+ prepareUpdate, removePlugin, setEnabled, scaffoldPlugin,
353
+ };