@argenalimbaev/template-agent 1.0.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 +107 -0
- package/bin/template-agent.mjs +9 -0
- package/package.json +41 -0
- package/registry/templates.json +361 -0
- package/scripts/create-project.mjs +161 -0
- package/scripts/recommend-template.mjs +39 -0
- package/scripts/registry.mjs +52 -0
- package/scripts/skill-adapters.mjs +30 -0
- package/scripts/template-ai-contract.mjs +56 -0
- package/skills/arg3n41ck-frontend-project/SKILL.md +15 -0
- package/src/catalog.mjs +106 -0
- package/src/cli.mjs +228 -0
- package/src/semver.mjs +16 -0
- package/src/skill-manager.mjs +85 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readRegistry, availableTemplates } from './registry.mjs';
|
|
5
|
+
|
|
6
|
+
// The host AI extracts requirements. This helper checks constraints, not natural language.
|
|
7
|
+
export function recommend(registry, requirements) {
|
|
8
|
+
const allowed = new Set(['requiredCapabilities', 'preferredCapabilities', 'excludedCapabilities', 'existingProject']);
|
|
9
|
+
if (!requirements || Object.keys(requirements).some(k => !allowed.has(k))) throw new Error('Invalid requirements fields.');
|
|
10
|
+
if (requirements.existingProject !== undefined && typeof requirements.existingProject !== 'boolean') throw new Error('existingProject must be boolean.');
|
|
11
|
+
for (const key of ['requiredCapabilities', 'preferredCapabilities', 'excludedCapabilities']) {
|
|
12
|
+
if (!Array.isArray(requirements[key]) || requirements[key].some(x => typeof x !== 'string' || !/^[a-z0-9-]+$/.test(x))) throw new Error(`Invalid ${key}.`);
|
|
13
|
+
}
|
|
14
|
+
if (requirements.existingProject) return { status: 'existing-project', candidates: [], reason: 'Inspect and modify existing code; do not scaffold over it.' };
|
|
15
|
+
const required = [...new Set(requirements.requiredCapabilities)];
|
|
16
|
+
const excluded = new Set(requirements.excludedCapabilities);
|
|
17
|
+
if (required.some(x => excluded.has(x))) return { status: 'conflicting-requirements', candidates: [] };
|
|
18
|
+
const preferred = [...new Set(requirements.preferredCapabilities)];
|
|
19
|
+
const considered = availableTemplates(registry).map(entry => ({
|
|
20
|
+
entry,
|
|
21
|
+
missing: required.filter(x => !entry.selection.capabilities.includes(x)),
|
|
22
|
+
conflicts: entry.selection.capabilities.filter(x => excluded.has(x)),
|
|
23
|
+
preferenceMatches: preferred.filter(x => entry.selection.capabilities.includes(x)).length,
|
|
24
|
+
}));
|
|
25
|
+
const candidates = considered.filter(x => !x.missing.length && !x.conflicts.length).sort((a, b) => b.preferenceMatches - a.preferenceMatches || a.entry.selection.complexity - b.entry.selection.complexity);
|
|
26
|
+
const details = candidates.map(x => ({ id: x.entry.id, preferenceMatches: x.preferenceMatches, complexity: x.entry.selection.complexity, limitations: x.entry.selection.limitations }));
|
|
27
|
+
if (!details.length) return { status: 'no-match', candidates: [], rejected: considered.map(x => ({ id: x.entry.id, missing: x.missing, conflicts: x.conflicts })) };
|
|
28
|
+
const [first, second] = details;
|
|
29
|
+
const tied = second && first.preferenceMatches === second.preferenceMatches && first.complexity === second.complexity;
|
|
30
|
+
return { status: tied ? 'ambiguous' : 'selected', ...(tied ? {} : { template: first.id }), candidates: details };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
34
|
+
try {
|
|
35
|
+
if (process.argv.length !== 3) throw new Error('Usage: node scripts/recommend-template.mjs <requirements.json>');
|
|
36
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
|
+
console.log(JSON.stringify(recommend(readRegistry(resolve(root, 'registry/templates.json')), JSON.parse(readFileSync(process.argv[2], 'utf8'))), null, 2));
|
|
38
|
+
} catch (error) { console.error(`ERROR: ${error.message}`); process.exitCode = 1; }
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const token = value => typeof value === 'string' && /^[a-z0-9-]+$/.test(value);
|
|
4
|
+
const strings = value => Array.isArray(value) && value.every(x => typeof x === 'string' && x.trim());
|
|
5
|
+
const tokens = value => strings(value) && value.every(token) && new Set(value).size === value.length;
|
|
6
|
+
export const safePath = value => typeof value === 'string' && value.length > 0 && !value.includes('\\') && !value.startsWith('/') && !value.includes(':') && value.split('/').every(p => p && !['.', '..', '.git'].includes(p));
|
|
7
|
+
|
|
8
|
+
function validRepository(value) {
|
|
9
|
+
if (/^\.\.?\/[^\s]+$/.test(value)) return true; // Explicit local test/development source.
|
|
10
|
+
if (/^git@[a-zA-Z0-9.-]+:[\w./-]+$/.test(value)) return !value.includes('/../');
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value);
|
|
13
|
+
return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password && !url.search && !url.hash && url.pathname !== '/' && !/\s/.test(value);
|
|
14
|
+
} catch { return false; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateRegistry(registry) {
|
|
18
|
+
if (![2, 3].includes(registry?.version) || !Array.isArray(registry.templates)) throw new Error('Registry requires version 2 or 3 and templates array.');
|
|
19
|
+
const allowedRegistryFields = new Set(registry.version === 3 ? ['version', 'minCliVersion', 'templates'] : ['version', 'templates']);
|
|
20
|
+
if (Object.keys(registry).some(key => !allowedRegistryFields.has(key))) throw new Error('Unknown registry field; executable hooks are forbidden.');
|
|
21
|
+
if (registry.version === 3 && (typeof registry.minCliVersion !== 'string' || !/^\d+\.\d+\.\d+$/.test(registry.minCliVersion))) {
|
|
22
|
+
throw new Error('Registry version 3 requires minCliVersion.');
|
|
23
|
+
}
|
|
24
|
+
const ids = new Set();
|
|
25
|
+
const allowed = new Set(['id', 'name', 'description', 'profile', 'repository', 'ref', 'commit', 'stack', 'skills', 'enabled', 'selection', 'project']);
|
|
26
|
+
for (const entry of registry.templates) {
|
|
27
|
+
if (!entry || typeof entry !== 'object' || !token(entry.id) || ids.has(entry.id)) throw new Error('Invalid/duplicate template ID.');
|
|
28
|
+
ids.add(entry.id);
|
|
29
|
+
if (Object.keys(entry).some(key => !allowed.has(key))) throw new Error('Unknown registry field; executable hooks are forbidden.');
|
|
30
|
+
for (const key of ['name', 'description', 'profile', 'repository', 'ref']) {
|
|
31
|
+
if (typeof entry[key] !== 'string' || !entry[key].trim()) throw new Error(`Missing ${key}: ${entry.id}`);
|
|
32
|
+
}
|
|
33
|
+
if (typeof entry.enabled !== 'boolean') throw new Error('enabled must be boolean.');
|
|
34
|
+
if (!/^v\d+\.\d+\.\d+$/.test(entry.ref)) throw new Error('Use a versioned release tag.');
|
|
35
|
+
if (entry.commit !== undefined && !/^[a-f0-9]{40}$/.test(entry.commit)) throw new Error('Invalid commit pin.');
|
|
36
|
+
if (!entry.repository.startsWith('.') && !entry.commit) throw new Error('Remote releases require a commit pin.');
|
|
37
|
+
if (!tokens(entry.stack) || !entry.stack.length || !tokens(entry.skills) || !entry.skills.length || !token(entry.profile)) throw new Error('Invalid stack, skills or profile.');
|
|
38
|
+
if (!validRepository(entry.repository)) throw new Error('Use HTTPS, Git SSH or an explicit relative development source; never embedded credentials.');
|
|
39
|
+
const s = entry.selection;
|
|
40
|
+
if (!s || Object.keys(s).some(k => !['capabilities', 'useWhen', 'avoidWhen', 'limitations', 'complexity'].includes(k)) || !tokens(s.capabilities) || !s.capabilities.length || !strings(s.useWhen) || !s.useWhen.length || !strings(s.avoidWhen) || !strings(s.limitations) || !Number.isInteger(s.complexity) || s.complexity < 1) throw new Error('Invalid selection metadata.');
|
|
41
|
+
const p = entry.project;
|
|
42
|
+
if (!p || Object.keys(p).some(k => !['renamePackage', 'requiredFiles'].includes(k)) || typeof p.renamePackage !== 'boolean' || !Array.isArray(p.requiredFiles) || !p.requiredFiles.every(safePath)) throw new Error('Invalid project contract.');
|
|
43
|
+
if (p.renamePackage && !p.requiredFiles.includes('package.json')) throw new Error('Renaming requires root package.json in requiredFiles.');
|
|
44
|
+
}
|
|
45
|
+
return registry;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readRegistry(file) {
|
|
49
|
+
return validateRegistry(JSON.parse(readFileSync(file, 'utf8')));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const availableTemplates = registry => registry.templates.filter(entry => entry.enabled);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, lstatSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const adapters = ['.agents/skills', '.claude/skills', '.codex/skills'];
|
|
5
|
+
|
|
6
|
+
export function writeSkillAdapters(directory, skills) {
|
|
7
|
+
for (const adapter of adapters) {
|
|
8
|
+
// Remove the checkout's legacy link (or Git's text representation on Windows), not its destination.
|
|
9
|
+
rmSync(join(directory, adapter), { recursive: true, force: true });
|
|
10
|
+
for (const name of skills) {
|
|
11
|
+
const canonical = `.ai/skills/${name}/SKILL.md`;
|
|
12
|
+
const text = readFileSync(join(directory, canonical), 'utf8');
|
|
13
|
+
const frontmatter = text.match(/^---\r?\n[\s\S]*?\r?\n---/)?.[0];
|
|
14
|
+
if (!frontmatter) throw new Error(`Skill needs YAML frontmatter: ${name}`);
|
|
15
|
+
const folder = join(directory, adapter, name);
|
|
16
|
+
mkdirSync(folder, { recursive: true });
|
|
17
|
+
writeFileSync(join(folder, 'SKILL.md'), `${frontmatter}\n\nRead the canonical skill at project root: \`${canonical}\`.\nResolve its references and scripts relative to the canonical skill directory, not this forwarding file.\nProject AGENTS.md and actual source override generic skill examples.\n`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateSkillAdapters(directory, skills) {
|
|
23
|
+
for (const adapter of adapters) {
|
|
24
|
+
if (!lstatSync(join(directory, adapter)).isDirectory() || lstatSync(join(directory, adapter)).isSymbolicLink()) throw new Error('Expected portable adapter directory.');
|
|
25
|
+
for (const name of skills) {
|
|
26
|
+
const file = join(directory, adapter, name, 'SKILL.md');
|
|
27
|
+
if (!readFileSync(file, 'utf8').includes(`.ai/skills/${name}/SKILL.md`)) throw new Error(`Invalid forwarding skill: ${file}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const adapters = ['.agents/skills', '.claude/skills', '.codex/skills'];
|
|
5
|
+
const key = value => typeof value === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
6
|
+
const object = value => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const list = value => Array.isArray(value) && value.every(key) && new Set(value).size === value.length;
|
|
8
|
+
const exact = (value, keys) => object(value) && Object.keys(value).every(key => keys.includes(key)) && keys.every(key => Object.hasOwn(value, key));
|
|
9
|
+
|
|
10
|
+
function local(root, path, directory = false) {
|
|
11
|
+
const resolved = realpathSync(join(root, path));
|
|
12
|
+
const rel = relative(realpathSync(root), resolved);
|
|
13
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error(`Unsafe path: ${path}`);
|
|
14
|
+
const stat = lstatSync(resolved);
|
|
15
|
+
if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error(`Invalid path type: ${path}`);
|
|
16
|
+
return resolved;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const read = (root, path) => readFileSync(local(root, path), 'utf8');
|
|
20
|
+
|
|
21
|
+
export function readManifest(root) {
|
|
22
|
+
const manifest = JSON.parse(read(root, '.ai/workflows.json'));
|
|
23
|
+
if (!exact(manifest, ['version', 'skills', 'tasks', 'risks']) || manifest.version !== 1 || !list(manifest.skills) || !manifest.skills.length || !object(manifest.tasks) || !Object.keys(manifest.tasks).length || !object(manifest.risks)) {
|
|
24
|
+
throw new Error('Invalid workflow manifest.');
|
|
25
|
+
}
|
|
26
|
+
for (const [name, task] of Object.entries(manifest.tasks)) {
|
|
27
|
+
if (!key(name) || !exact(task, ['mode', 'skills']) || !['read', 'change'].includes(task.mode) || !list(task.skills) || task.skills.some(skill => !manifest.skills.includes(skill))) throw new Error(`Invalid task: ${name}`);
|
|
28
|
+
}
|
|
29
|
+
for (const [name, risk] of Object.entries(manifest.risks)) {
|
|
30
|
+
if (!key(name) || !exact(risk, ['skills', 'checks']) || !list(risk.skills) || risk.skills.some(skill => !manifest.skills.includes(skill)) || !Array.isArray(risk.checks) || !risk.checks.length || risk.checks.some(check => typeof check !== 'string' || !check.trim())) throw new Error(`Invalid risk: ${name}`);
|
|
31
|
+
}
|
|
32
|
+
return manifest;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateTemplateAiContract(root) {
|
|
36
|
+
const manifest = readManifest(root);
|
|
37
|
+
for (const name of ['project-documentation-wiki', 'graphify']) {
|
|
38
|
+
if (!manifest.skills.includes(name)) throw new Error(`Required knowledge skill missing: ${name}`);
|
|
39
|
+
}
|
|
40
|
+
for (const path of ['AGENTS.md', '.ai/context.mjs', '.ai/WORKFLOW.md', '.codex-harness/AGENT_GRAPH.md', '.codex-harness/VERIFICATION.md', '.wiki/index.md']) local(root, path);
|
|
41
|
+
const inventory = readdirSync(local(root, '.ai/skills', true)).sort();
|
|
42
|
+
if (JSON.stringify(inventory) !== JSON.stringify([...manifest.skills].sort())) throw new Error('Skill inventory differs from manifest.');
|
|
43
|
+
for (const name of manifest.skills) {
|
|
44
|
+
const text = read(root, `.ai/skills/${name}/SKILL.md`);
|
|
45
|
+
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
46
|
+
if (!frontmatter || !frontmatter[1].split(/\r?\n/).includes(`name: ${name}`) || !/^description:\s*\S/m.test(frontmatter[1])) throw new Error(`Invalid skill metadata: ${name}`);
|
|
47
|
+
for (const adapter of adapters) {
|
|
48
|
+
const adapterPath = join(root, adapter);
|
|
49
|
+
if (lstatSync(adapterPath).isSymbolicLink()) throw new Error(`Unsafe adapter link: ${adapter}`);
|
|
50
|
+
local(root, adapter, true);
|
|
51
|
+
const forward = read(root, `${adapter}/${name}/SKILL.md`);
|
|
52
|
+
if (!forward.includes(`.ai/skills/${name}/SKILL.md`) || !forward.startsWith(frontmatter[0])) throw new Error(`Invalid adapter: ${adapter}/${name}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { status: 'valid', skills: manifest.skills.length };
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: arg3n41ck-frontend-project
|
|
3
|
+
description: Select and create a new frontend project from the Template Agent catalog. Use when the user explicitly asks to start, scaffold, generate, or create a new React, Next.js, CRM dashboard, fullstack project, or new layout from scratch. Do not use for architecture discussion or changes inside an existing project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Use this flow only when the user wants a new project and a terminal is available.
|
|
7
|
+
|
|
8
|
+
1. Confirm the target does not already contain a project. Never scaffold over existing code.
|
|
9
|
+
2. Run `npx --yes @argenalimbaev/template-agent@1 list --json` and read the current catalog.
|
|
10
|
+
3. Translate the request into factual capabilities. Do not infer a backend merely from CRM, SaaS, auth, API, or login. Use `recommend --requirements <file> --json` to check the decision.
|
|
11
|
+
4. If one template is compatible, briefly state its ID, reason, and important limitation. If candidates are tied, no match exists, or a requested constraint conflicts with the catalog, ask one consequential question. Do not silently substitute a stack or merge templates.
|
|
12
|
+
5. Create the project with `npx --yes @argenalimbaev/template-agent@1 create <new-directory> --template <id> --brief-file <accepted-brief-file>`. Do not install dependencies, run hooks, publish, or expose secrets.
|
|
13
|
+
6. Read the generated project's `AGENTS.md`, its project brief, and verification guide. Continue implementation only when the user asked for work beyond starter creation.
|
|
14
|
+
|
|
15
|
+
If the environment has no shell or filesystem access, explain that it cannot materialize a project and provide the manual CLI command. For manual creation, `npx --yes @argenalimbaev/template-agent@1 create` asks only for a template and project name.
|
package/src/catalog.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir, platform } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { validateRegistry } from '../scripts/registry.mjs';
|
|
5
|
+
import { compareSemver } from './semver.mjs';
|
|
6
|
+
|
|
7
|
+
export const CLI_VERSION = '1.0.0';
|
|
8
|
+
export const CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
export const MAX_CATALOG_BYTES = 1024 * 1024;
|
|
10
|
+
export const DEFAULT_RELEASE_API_URL = 'https://api.github.com/repos/arg3n41ck/frontend-template-hub/releases?per_page=100';
|
|
11
|
+
|
|
12
|
+
export function defaultCacheDirectory({ home = homedir(), env = process.env, os = platform() } = {}) {
|
|
13
|
+
if (os === 'darwin') return join(home, 'Library', 'Caches', 'template-agent');
|
|
14
|
+
if (os === 'win32') return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'template-agent', 'Cache');
|
|
15
|
+
return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'template-agent');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function catalogFile(cacheDirectory) {
|
|
19
|
+
return join(cacheDirectory, 'catalog.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readCatalogFile(file) {
|
|
23
|
+
return validateRegistry(JSON.parse(readFileSync(file, 'utf8')));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureCompatible(registry, cliVersion) {
|
|
27
|
+
if (registry.minCliVersion && compareSemver(cliVersion, registry.minCliVersion) < 0) {
|
|
28
|
+
throw new Error(`Catalog requires CLI ${registry.minCliVersion} or newer; run npx @argenalimbaev/template-agent@1 update.`);
|
|
29
|
+
}
|
|
30
|
+
return registry;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function saveCatalog(cacheDirectory, registry) {
|
|
34
|
+
mkdirSync(cacheDirectory, { recursive: true });
|
|
35
|
+
const file = catalogFile(cacheDirectory);
|
|
36
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
37
|
+
writeFileSync(temporary, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600 });
|
|
38
|
+
renameSync(temporary, file);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readResponseJson(response, label) {
|
|
42
|
+
if (!response.ok) throw new Error(`${label} returned HTTP ${response.status}.`);
|
|
43
|
+
const length = Number(response.headers.get('content-length') || '0');
|
|
44
|
+
if (length > MAX_CATALOG_BYTES) throw new Error(`${label} is too large.`);
|
|
45
|
+
const text = await response.text();
|
|
46
|
+
if (Buffer.byteLength(text) > MAX_CATALOG_BYTES) throw new Error(`${label} is too large.`);
|
|
47
|
+
return JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchRemoteCatalog({ fetchImpl, releaseApiUrl, timeoutMs }) {
|
|
51
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
52
|
+
const releasesResponse = await fetchImpl(releaseApiUrl, {
|
|
53
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'template-agent' },
|
|
54
|
+
signal,
|
|
55
|
+
});
|
|
56
|
+
const releases = await readResponseJson(releasesResponse, 'Catalog release endpoint');
|
|
57
|
+
if (!Array.isArray(releases)) throw new Error('Catalog release endpoint returned an invalid payload.');
|
|
58
|
+
const release = releases.find(item => !item.draft && !item.prerelease && /^catalog-v\d+\.\d+\.\d+$/.test(item.tag_name));
|
|
59
|
+
const asset = release?.assets?.find(item => item.name === 'templates.json' && typeof item.browser_download_url === 'string');
|
|
60
|
+
if (!asset) throw new Error('No published catalog-v release with templates.json was found.');
|
|
61
|
+
const assetUrl = new URL(asset.browser_download_url);
|
|
62
|
+
if (assetUrl.protocol !== 'https:') throw new Error('Catalog asset must use HTTPS.');
|
|
63
|
+
const catalogResponse = await fetchImpl(assetUrl.href, {
|
|
64
|
+
headers: { accept: 'application/json', 'user-agent': 'template-agent' },
|
|
65
|
+
signal,
|
|
66
|
+
});
|
|
67
|
+
return validateRegistry(await readResponseJson(catalogResponse, 'Catalog asset'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function resolveCatalog({
|
|
71
|
+
bundledRegistry,
|
|
72
|
+
cacheDirectory = defaultCacheDirectory(),
|
|
73
|
+
releaseApiUrl = DEFAULT_RELEASE_API_URL,
|
|
74
|
+
fetchImpl = globalThis.fetch,
|
|
75
|
+
cliVersion = CLI_VERSION,
|
|
76
|
+
forceRefresh = false,
|
|
77
|
+
timeoutMs = 10_000,
|
|
78
|
+
now = Date.now(),
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (!bundledRegistry) throw new Error('Bundled catalog is required.');
|
|
81
|
+
const cached = catalogFile(cacheDirectory);
|
|
82
|
+
if (!forceRefresh && existsSync(cached) && now - statSync(cached).mtimeMs < CATALOG_CACHE_TTL_MS) {
|
|
83
|
+
try {
|
|
84
|
+
return { registry: ensureCompatible(readCatalogFile(cached), cliVersion), source: 'cache', stale: false };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (/requires CLI/.test(error.message)) throw error;
|
|
87
|
+
// A damaged cache is never trusted.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const registry = ensureCompatible(await fetchRemoteCatalog({ fetchImpl, releaseApiUrl, timeoutMs }), cliVersion);
|
|
92
|
+
saveCatalog(cacheDirectory, registry);
|
|
93
|
+
return { registry, source: 'remote', stale: false };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (/requires CLI/.test(error.message)) throw error;
|
|
96
|
+
if (existsSync(cached)) {
|
|
97
|
+
try {
|
|
98
|
+
return { registry: ensureCompatible(readCatalogFile(cached), cliVersion), source: 'cache', stale: true, warning: error.message };
|
|
99
|
+
} catch (cacheError) {
|
|
100
|
+
if (/requires CLI/.test(cacheError.message)) throw cacheError;
|
|
101
|
+
// Fall through to the bundled release.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { registry: ensureCompatible(validateRegistry(bundledRegistry), cliVersion), source: 'bundled', stale: true, warning: error.message };
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createProject } from '../scripts/create-project.mjs';
|
|
7
|
+
import { availableTemplates, readRegistry } from '../scripts/registry.mjs';
|
|
8
|
+
import { recommend } from '../scripts/recommend-template.mjs';
|
|
9
|
+
import { CLI_VERSION, defaultCacheDirectory, resolveCatalog } from './catalog.mjs';
|
|
10
|
+
import { compareSemver } from './semver.mjs';
|
|
11
|
+
import { detectClients, findDuplicateSkillNames, installSkill, skillTargets, uninstallSkill } from './skill-manager.mjs';
|
|
12
|
+
|
|
13
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const skillSource = join(root, 'skills', 'arg3n41ck-frontend-project', 'SKILL.md');
|
|
15
|
+
const bundledCatalog = () => readRegistry(join(root, 'registry', 'templates.json'));
|
|
16
|
+
const valueOptions = new Set(['template', 'brief-file', 'requirements', 'client']);
|
|
17
|
+
const booleanOptions = new Set(['json', 'dry-run', 'allow-third-party', 'keep-template-history', 'purge-cache', 'help']);
|
|
18
|
+
const commands = new Set(['create', 'list', 'recommend', 'setup', 'doctor', 'update', 'check', 'uninstall', 'help']);
|
|
19
|
+
const windowsReserved = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
20
|
+
const optionKey = name => name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
21
|
+
|
|
22
|
+
export function parseArguments(argv) {
|
|
23
|
+
const [first = 'help', ...rest] = argv;
|
|
24
|
+
if (!commands.has(first)) throw new Error(`Unknown command: ${first}`);
|
|
25
|
+
const options = {};
|
|
26
|
+
let target;
|
|
27
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
28
|
+
const argument = rest[index];
|
|
29
|
+
if (argument === '--help' || argument === '-h') {
|
|
30
|
+
options.help = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (argument.startsWith('--')) {
|
|
34
|
+
const name = argument.slice(2);
|
|
35
|
+
if (booleanOptions.has(name)) {
|
|
36
|
+
options[optionKey(name)] = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!valueOptions.has(name)) throw new Error(`Unknown option: ${argument}`);
|
|
40
|
+
const value = rest[++index];
|
|
41
|
+
if (!value || value.startsWith('--')) throw new Error(`${argument} needs a value.`);
|
|
42
|
+
options[optionKey(name)] = value;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (target !== undefined) throw new Error('Only one target directory is allowed.');
|
|
46
|
+
target = argument;
|
|
47
|
+
}
|
|
48
|
+
return { command: first, ...(target === undefined ? {} : { target }), options };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateInteractiveProjectName(value) {
|
|
52
|
+
const name = typeof value === 'string' ? value.trim() : '';
|
|
53
|
+
if (!name || name.length > 100 || name === '.' || name === '..' || windowsReserved.test(name)) throw new Error('Project name is invalid.');
|
|
54
|
+
if (name.includes('/') || name.includes('\\') || /[<>:"|?*\u0000-\u001f]/.test(name) || name.endsWith('.') || name.endsWith(' ')) throw new Error('Project name is invalid.');
|
|
55
|
+
return name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function print(value, { json, output }) {
|
|
59
|
+
output(json ? JSON.stringify(value, null, 2) : value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isFirstParty(entry) {
|
|
63
|
+
try {
|
|
64
|
+
const source = new URL(entry.repository);
|
|
65
|
+
return source.protocol === 'https:' && source.hostname === 'github.com' && source.pathname.startsWith('/arg3n41ck/');
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertSourcePolicy(entry, allowThirdParty) {
|
|
72
|
+
if (!isFirstParty(entry) && !allowThirdParty) {
|
|
73
|
+
throw new Error(`Template ${entry.id} is not a first-party source. Re-run with --allow-third-party only after reviewing its repository.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatTemplates(templates) {
|
|
78
|
+
return templates.map((entry, index) => `${index + 1}. ${entry.name} (${entry.id}) — ${entry.description}`).join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function interactivePrompt() {
|
|
82
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
83
|
+
return {
|
|
84
|
+
ask: question => readline.question(question),
|
|
85
|
+
close: () => readline.close(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function chooseCreateArguments({ templates, target, template, prompt }) {
|
|
90
|
+
let selectedId = template;
|
|
91
|
+
if (!selectedId) {
|
|
92
|
+
const answer = await prompt.ask(`Choose a template:\n${formatTemplates(templates)}\n> `);
|
|
93
|
+
const selected = templates[Number(answer) - 1];
|
|
94
|
+
if (!selected) throw new Error('Choose a template number from the list.');
|
|
95
|
+
selectedId = selected.id;
|
|
96
|
+
}
|
|
97
|
+
let selectedTarget = target;
|
|
98
|
+
if (!selectedTarget) selectedTarget = validateInteractiveProjectName(await prompt.ask('Project name: '));
|
|
99
|
+
return { template: selectedId, target: selectedTarget };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function selectedClients(option, environment) {
|
|
103
|
+
if (option === 'all') return ['codex', 'claude'];
|
|
104
|
+
if (option) return [option];
|
|
105
|
+
const detected = detectClients(environment);
|
|
106
|
+
if (!detected.length) throw new Error('No supported client was detected. Use setup --client codex or setup --client claude.');
|
|
107
|
+
return detected;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function usage() {
|
|
111
|
+
return `Template Agent ${CLI_VERSION}\n\n` +
|
|
112
|
+
`npx --yes @argenalimbaev/template-agent@1 setup [--client codex|claude|all]\n` +
|
|
113
|
+
`npx --yes @argenalimbaev/template-agent@1 create [project-name] [--template id]\n` +
|
|
114
|
+
`template-agent list [--json]\n` +
|
|
115
|
+
`template-agent doctor | update | check [project-path] | uninstall`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function catalogForCommand(options, dependencies) {
|
|
119
|
+
return resolveCatalog({ bundledRegistry: bundledCatalog(), forceRefresh: !!options.forceRefresh, ...dependencies.catalog });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function runCli(argv, dependencies = {}) {
|
|
123
|
+
const parsed = parseArguments(argv);
|
|
124
|
+
const output = dependencies.output || (value => console.log(value));
|
|
125
|
+
const cwd = dependencies.cwd || process.cwd();
|
|
126
|
+
const environment = dependencies.environment || {};
|
|
127
|
+
if (parsed.options.help || parsed.command === 'help') {
|
|
128
|
+
output(usage());
|
|
129
|
+
return { status: 'help' };
|
|
130
|
+
}
|
|
131
|
+
if (parsed.command === 'setup' || parsed.command === 'uninstall') {
|
|
132
|
+
const clients = parsed.command === 'uninstall' && !parsed.options.client
|
|
133
|
+
? ['codex', 'claude']
|
|
134
|
+
: selectedClients(parsed.options.client, environment);
|
|
135
|
+
const targets = skillTargets({ clients, ...environment });
|
|
136
|
+
const result = parsed.command === 'setup'
|
|
137
|
+
? installSkill({ targets, source: skillSource, dryRun: !!parsed.options.dryRun })
|
|
138
|
+
: uninstallSkill({ targets, dryRun: !!parsed.options.dryRun });
|
|
139
|
+
const response = { skills: result };
|
|
140
|
+
if (parsed.command === 'uninstall' && parsed.options.purgeCache) {
|
|
141
|
+
const cache = dependencies.cacheDirectory || defaultCacheDirectory(environment);
|
|
142
|
+
if (!parsed.options.dryRun) rmSync(cache, { recursive: true, force: true });
|
|
143
|
+
response.cache = parsed.options.dryRun ? 'would-remove' : 'removed';
|
|
144
|
+
}
|
|
145
|
+
print(response, { json: !!parsed.options.json, output });
|
|
146
|
+
return response;
|
|
147
|
+
}
|
|
148
|
+
if (parsed.command === 'doctor') {
|
|
149
|
+
const nodeOk = compareSemver(process.versions.node, '22.14.0') >= 0;
|
|
150
|
+
let gitOk = true;
|
|
151
|
+
try { execFileSync('git', ['--version'], { stdio: 'ignore' }); } catch { gitOk = false; }
|
|
152
|
+
const catalog = await catalogForCommand({}, dependencies);
|
|
153
|
+
const clients = detectClients(environment);
|
|
154
|
+
const result = {
|
|
155
|
+
node: { version: process.versions.node, supported: nodeOk },
|
|
156
|
+
git: { supported: gitOk },
|
|
157
|
+
catalog: { source: catalog.source, stale: catalog.stale, warning: catalog.warning || null },
|
|
158
|
+
clients,
|
|
159
|
+
duplicateSkillFiles: findDuplicateSkillNames(environment),
|
|
160
|
+
ok: nodeOk && gitOk,
|
|
161
|
+
};
|
|
162
|
+
print(result, { json: !!parsed.options.json, output });
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
if (parsed.command === 'update') {
|
|
166
|
+
const catalog = await catalogForCommand({ forceRefresh: true }, dependencies);
|
|
167
|
+
const clients = detectClients(environment);
|
|
168
|
+
const skills = clients.length ? installSkill({ targets: skillTargets({ clients, ...environment }), source: skillSource, dryRun: !!parsed.options.dryRun }) : [];
|
|
169
|
+
const result = { catalog: { source: catalog.source, stale: catalog.stale, warning: catalog.warning || null }, skills };
|
|
170
|
+
print(result, { json: !!parsed.options.json, output });
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
const catalog = await catalogForCommand({}, dependencies);
|
|
174
|
+
const templates = availableTemplates(catalog.registry);
|
|
175
|
+
if (parsed.command === 'list') {
|
|
176
|
+
const result = { source: catalog.source, templates };
|
|
177
|
+
print(parsed.options.json ? result : formatTemplates(templates), { json: !!parsed.options.json, output });
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
if (parsed.command === 'recommend') {
|
|
181
|
+
if (!parsed.options.requirements) throw new Error('recommend needs --requirements <file>.');
|
|
182
|
+
const requirements = JSON.parse(readFileSync(resolve(cwd, parsed.options.requirements), 'utf8'));
|
|
183
|
+
const result = recommend(catalog.registry, requirements);
|
|
184
|
+
print(result, { json: true, output });
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
if (parsed.command === 'check') {
|
|
188
|
+
const project = resolve(cwd, parsed.target || '.');
|
|
189
|
+
const provenancePath = join(project, '.template-provenance.json');
|
|
190
|
+
if (!existsSync(provenancePath)) throw new Error(`No .template-provenance.json found in ${project}.`);
|
|
191
|
+
const provenance = JSON.parse(readFileSync(provenancePath, 'utf8'));
|
|
192
|
+
const current = templates.find(entry => entry.id === provenance.template);
|
|
193
|
+
const result = {
|
|
194
|
+
project,
|
|
195
|
+
template: provenance.template,
|
|
196
|
+
current: current ? { ref: current.ref, commit: current.commit } : null,
|
|
197
|
+
generated: { ref: provenance.ref, commit: provenance.commit },
|
|
198
|
+
updateAvailable: !!current && (current.ref !== provenance.ref || current.commit !== provenance.commit),
|
|
199
|
+
};
|
|
200
|
+
print(result, { json: !!parsed.options.json, output });
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
if (parsed.command !== 'create') throw new Error(`Unsupported command: ${parsed.command}`);
|
|
204
|
+
let prompt;
|
|
205
|
+
try {
|
|
206
|
+
if ((!parsed.options.template || !parsed.target) && !dependencies.prompt && !process.stdin.isTTY) {
|
|
207
|
+
throw new Error('create needs a project name and --template outside an interactive terminal.');
|
|
208
|
+
}
|
|
209
|
+
if (!parsed.options.template || !parsed.target) prompt = dependencies.prompt || await interactivePrompt();
|
|
210
|
+
const selected = await chooseCreateArguments({ templates, target: parsed.target, template: parsed.options.template, prompt });
|
|
211
|
+
const entry = templates.find(item => item.id === selected.template);
|
|
212
|
+
if (!entry) throw new Error('Unknown template ID. Run template-agent list.');
|
|
213
|
+
assertSourcePolicy(entry, !!parsed.options.allowThirdParty);
|
|
214
|
+
const target = isAbsolute(selected.target) ? selected.target : resolve(cwd, selected.target);
|
|
215
|
+
if (parsed.options.dryRun) {
|
|
216
|
+
const result = { target, template: entry.id, source: catalog.source, dryRun: true };
|
|
217
|
+
print(result, { json: true, output });
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
const brief = parsed.options.briefFile ? readFileSync(resolve(cwd, parsed.options.briefFile), 'utf8') : undefined;
|
|
221
|
+
const metadata = createProject({ entry, target, brief, keepHistory: !!parsed.options.keepTemplateHistory });
|
|
222
|
+
const result = { target, source: catalog.source, metadata };
|
|
223
|
+
print(result, { json: true, output });
|
|
224
|
+
return result;
|
|
225
|
+
} finally {
|
|
226
|
+
prompt?.close?.();
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/semver.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const pattern = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
2
|
+
|
|
3
|
+
export function parseSemver(value) {
|
|
4
|
+
const match = typeof value === 'string' && value.match(pattern);
|
|
5
|
+
if (!match) throw new Error(`Invalid semantic version: ${value}`);
|
|
6
|
+
return match.slice(1).map(Number);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function compareSemver(left, right) {
|
|
10
|
+
const a = parseSemver(left);
|
|
11
|
+
const b = parseSemver(right);
|
|
12
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
13
|
+
if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
|
|
14
|
+
}
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const SKILL_NAME = 'arg3n41ck-frontend-project';
|
|
6
|
+
const managedMarker = '<!-- template-agent:managed -->';
|
|
7
|
+
|
|
8
|
+
export function skillTargets({ home = homedir(), env = process.env, clients = ['codex', 'claude'] } = {}) {
|
|
9
|
+
return clients.map(client => {
|
|
10
|
+
if (client === 'codex') {
|
|
11
|
+
const directory = join(home, '.agents', 'skills', SKILL_NAME);
|
|
12
|
+
return { client, directory, file: join(directory, 'SKILL.md') };
|
|
13
|
+
}
|
|
14
|
+
if (client === 'claude') {
|
|
15
|
+
const directory = join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'skills', SKILL_NAME);
|
|
16
|
+
return { client, directory, file: join(directory, 'SKILL.md') };
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Unsupported client: ${client}`);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function detectClients({ home = homedir(), env = process.env } = {}) {
|
|
23
|
+
const clients = [];
|
|
24
|
+
if (existsSync(join(home, '.agents')) || existsSync(join(home, '.codex'))) clients.push('codex');
|
|
25
|
+
if (existsSync(env.CLAUDE_CONFIG_DIR || join(home, '.claude'))) clients.push('claude');
|
|
26
|
+
return clients;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function managed(text) {
|
|
30
|
+
return text.includes(managedMarker);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function managedSkillContent(source) {
|
|
34
|
+
const text = readFileSync(source, 'utf8');
|
|
35
|
+
return `${managedMarker}\n${text}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeAtomic(file, content) {
|
|
39
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
40
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
41
|
+
writeFileSync(temporary, content, { mode: 0o600 });
|
|
42
|
+
renameSync(temporary, file);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function installSkill({ targets, source, dryRun = false }) {
|
|
46
|
+
const content = managedSkillContent(source);
|
|
47
|
+
return targets.map(target => {
|
|
48
|
+
const alreadyExists = existsSync(target.file);
|
|
49
|
+
if (alreadyExists) {
|
|
50
|
+
const current = readFileSync(target.file, 'utf8');
|
|
51
|
+
if (!managed(current)) throw new Error(`Refusing to overwrite unmanaged skill: ${target.file}`);
|
|
52
|
+
if (current === content) return { client: target.client, status: 'unchanged', file: target.file };
|
|
53
|
+
if (!dryRun) writeAtomic(`${target.file}.template-agent.bak`, current);
|
|
54
|
+
}
|
|
55
|
+
if (!dryRun) writeAtomic(target.file, content);
|
|
56
|
+
return { client: target.client, status: alreadyExists ? 'updated' : 'installed', file: target.file };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function uninstallSkill({ targets, dryRun = false }) {
|
|
61
|
+
return targets.map(target => {
|
|
62
|
+
if (!existsSync(target.file)) return { client: target.client, status: 'absent', file: target.file };
|
|
63
|
+
if (!managed(readFileSync(target.file, 'utf8'))) throw new Error(`Refusing to remove unmanaged skill: ${target.file}`);
|
|
64
|
+
if (!dryRun) {
|
|
65
|
+
rmSync(target.file);
|
|
66
|
+
rmSync(`${target.file}.template-agent.bak`, { force: true });
|
|
67
|
+
if (existsSync(target.directory) && readdirSync(target.directory).length === 0) rmdirSync(target.directory);
|
|
68
|
+
}
|
|
69
|
+
return { client: target.client, status: 'removed', file: target.file };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function findDuplicateSkillNames({ home = homedir(), env = process.env } = {}) {
|
|
74
|
+
const roots = [join(home, '.agents', 'skills'), join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'skills')];
|
|
75
|
+
const duplicates = [];
|
|
76
|
+
for (const root of roots) {
|
|
77
|
+
if (!existsSync(root)) continue;
|
|
78
|
+
for (const folder of readdirSync(root)) {
|
|
79
|
+
const file = join(root, folder, 'SKILL.md');
|
|
80
|
+
if (folder === SKILL_NAME || !existsSync(file)) continue;
|
|
81
|
+
if (readFileSync(file, 'utf8').match(new RegExp(`^name:\\s*${SKILL_NAME}\\s*$`, 'm'))) duplicates.push(file);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return duplicates;
|
|
85
|
+
}
|