skill-check 0.1.1 → 1.1.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.
@@ -0,0 +1,51 @@
1
+ export interface ParsedGitHubTarget {
2
+ originalUrl: string;
3
+ owner: string;
4
+ repo: string;
5
+ cloneUrl: string;
6
+ ref?: string;
7
+ subpath?: string;
8
+ }
9
+ export interface MaterializedRemoteTarget {
10
+ path: string;
11
+ cleanup: () => void;
12
+ metadata: ParsedGitHubTarget & {
13
+ tempDir: string;
14
+ checkoutPath: string;
15
+ };
16
+ }
17
+ export type RemoteTargetProgressEvent = {
18
+ type: 'clone_start';
19
+ cloneUrl: string;
20
+ ref?: string;
21
+ } | {
22
+ type: 'clone_done';
23
+ checkoutPath: string;
24
+ } | {
25
+ type: 'subpath_start';
26
+ subpath: string;
27
+ } | {
28
+ type: 'ready';
29
+ targetPath: string;
30
+ } | {
31
+ type: 'failed';
32
+ message: string;
33
+ };
34
+ interface CommandResult {
35
+ status: number | null;
36
+ stdout: string;
37
+ stderr: string;
38
+ error?: NodeJS.ErrnoException;
39
+ }
40
+ type CommandRunner = (command: string, args: string[], cwd?: string) => CommandResult;
41
+ interface MaterializeDeps {
42
+ runCommand?: CommandRunner;
43
+ mkdtempSync?: (prefix: string) => string;
44
+ removeDir?: (target: string) => void;
45
+ pathExists?: (target: string) => boolean;
46
+ onProgress?: (event: RemoteTargetProgressEvent) => void;
47
+ }
48
+ export declare function isGitHubRepoUrl(input: string): boolean;
49
+ export declare function parseGitHubTarget(input: string): ParsedGitHubTarget;
50
+ export declare function materializeRemoteTarget(input: string, deps?: MaterializeDeps): MaterializedRemoteTarget;
51
+ export {};
@@ -0,0 +1,191 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { CliError } from './errors.js';
6
+ function decodePathPart(value, label) {
7
+ try {
8
+ return decodeURIComponent(value);
9
+ }
10
+ catch {
11
+ throw new CliError(`Invalid GitHub URL: unable to decode ${label}.`, 2);
12
+ }
13
+ }
14
+ function parseAsUrl(input) {
15
+ try {
16
+ return new URL(input);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function defaultRunCommand(command, args, cwd) {
23
+ const result = spawnSync(command, args, {
24
+ cwd,
25
+ encoding: 'utf8',
26
+ });
27
+ return {
28
+ status: result.status,
29
+ stdout: result.stdout ?? '',
30
+ stderr: result.stderr ?? '',
31
+ error: result.error,
32
+ };
33
+ }
34
+ function ensureWithinCheckout(baseDir, candidatePath) {
35
+ const relative = path.relative(baseDir, candidatePath);
36
+ if (relative.startsWith('..') ||
37
+ path.isAbsolute(relative) ||
38
+ relative === '') {
39
+ throw new CliError('Invalid GitHub tree subpath: resolved path escapes repository root.', 2);
40
+ }
41
+ }
42
+ function trimErrorText(text) {
43
+ return text.trim().split('\n').slice(0, 3).join(' ').trim();
44
+ }
45
+ export function isGitHubRepoUrl(input) {
46
+ const url = parseAsUrl(input);
47
+ if (!url)
48
+ return false;
49
+ if (url.hostname.toLowerCase() !== 'github.com')
50
+ return false;
51
+ const segments = url.pathname.split('/').filter(Boolean);
52
+ return segments.length >= 2;
53
+ }
54
+ export function parseGitHubTarget(input) {
55
+ const url = parseAsUrl(input);
56
+ if (!url) {
57
+ throw new CliError(`Invalid GitHub URL: "${input}".`, 2);
58
+ }
59
+ const host = url.hostname.toLowerCase();
60
+ if (host !== 'github.com') {
61
+ throw new CliError(`Unsupported URL host "${url.hostname}". Only github.com is supported.`, 2);
62
+ }
63
+ if (url.search || url.hash) {
64
+ throw new CliError('GitHub URL targets must not include query params or hash fragments.', 2);
65
+ }
66
+ const segments = url.pathname.split('/').filter(Boolean);
67
+ if (segments.length < 2) {
68
+ throw new CliError('Invalid GitHub URL: expected https://github.com/<owner>/<repo>.', 2);
69
+ }
70
+ const owner = decodePathPart(segments[0], 'owner');
71
+ const repoRaw = decodePathPart(segments[1], 'repo');
72
+ const repo = repoRaw.endsWith('.git') ? repoRaw.slice(0, -4) : repoRaw;
73
+ if (!owner || !repo) {
74
+ throw new CliError('Invalid GitHub URL: owner and repo must be non-empty.', 2);
75
+ }
76
+ let ref;
77
+ let subpath;
78
+ if (segments.length > 2) {
79
+ if (segments[2] !== 'tree') {
80
+ throw new CliError('Unsupported GitHub URL path. Use repo root or /tree/<ref>/<subpath>.', 2);
81
+ }
82
+ if (segments.length < 4) {
83
+ throw new CliError('Invalid GitHub tree URL: missing <ref> segment after /tree/.', 2);
84
+ }
85
+ ref = decodePathPart(segments[3], 'ref');
86
+ if (!ref) {
87
+ throw new CliError('Invalid GitHub tree URL: <ref> cannot be empty.', 2);
88
+ }
89
+ if (segments.length > 4) {
90
+ subpath = segments
91
+ .slice(4)
92
+ .map((segment, index) => decodePathPart(segment, `tree subpath segment ${index + 1}`))
93
+ .join('/');
94
+ }
95
+ }
96
+ return {
97
+ originalUrl: input,
98
+ owner,
99
+ repo,
100
+ cloneUrl: `https://github.com/${owner}/${repo}.git`,
101
+ ref,
102
+ subpath,
103
+ };
104
+ }
105
+ export function materializeRemoteTarget(input, deps = {}) {
106
+ const onProgress = deps.onProgress;
107
+ let parsed;
108
+ try {
109
+ parsed = parseGitHubTarget(input);
110
+ }
111
+ catch (error) {
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ onProgress?.({ type: 'failed', message });
114
+ throw error;
115
+ }
116
+ const mkdtempSync = deps.mkdtempSync ?? ((prefix) => fs.mkdtempSync(prefix));
117
+ const removeDir = deps.removeDir ??
118
+ ((target) => fs.rmSync(target, { recursive: true, force: true }));
119
+ const pathExists = deps.pathExists ?? ((target) => fs.existsSync(target));
120
+ const runCommand = deps.runCommand ?? defaultRunCommand;
121
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), 'skill-check-remote-'));
122
+ const checkoutPath = path.join(tempDir, 'repo');
123
+ const cleanup = () => {
124
+ removeDir(tempDir);
125
+ };
126
+ try {
127
+ const cloneArgs = ['clone', '--depth', '1'];
128
+ if (parsed.ref) {
129
+ cloneArgs.push('--branch', parsed.ref, '--single-branch');
130
+ }
131
+ cloneArgs.push(parsed.cloneUrl, checkoutPath);
132
+ onProgress?.({
133
+ type: 'clone_start',
134
+ cloneUrl: parsed.cloneUrl,
135
+ ref: parsed.ref,
136
+ });
137
+ const result = runCommand('git', cloneArgs);
138
+ if (result.error?.code === 'ENOENT') {
139
+ throw new CliError('git is required to scan GitHub URL targets but was not found in PATH.', 2);
140
+ }
141
+ if (result.error) {
142
+ throw new CliError(`Failed to clone GitHub URL target: ${result.error.message}`, 2);
143
+ }
144
+ if (result.status !== 0) {
145
+ const details = trimErrorText(result.stderr || result.stdout);
146
+ throw new CliError(`git clone failed for ${parsed.cloneUrl}${details ? `: ${details}` : ''}`, 2);
147
+ }
148
+ onProgress?.({
149
+ type: 'clone_done',
150
+ checkoutPath,
151
+ });
152
+ if (!pathExists(checkoutPath)) {
153
+ throw new CliError('Remote checkout did not produce a repository directory.', 2);
154
+ }
155
+ let targetPath = checkoutPath;
156
+ if (parsed.subpath) {
157
+ onProgress?.({
158
+ type: 'subpath_start',
159
+ subpath: parsed.subpath,
160
+ });
161
+ const resolvedSubpath = path.resolve(checkoutPath, parsed.subpath);
162
+ ensureWithinCheckout(checkoutPath, resolvedSubpath);
163
+ if (!pathExists(resolvedSubpath)) {
164
+ throw new CliError(`GitHub tree subpath not found in checkout: ${parsed.subpath}`, 2);
165
+ }
166
+ targetPath = resolvedSubpath;
167
+ }
168
+ onProgress?.({
169
+ type: 'ready',
170
+ targetPath,
171
+ });
172
+ return {
173
+ path: targetPath,
174
+ cleanup,
175
+ metadata: {
176
+ ...parsed,
177
+ tempDir,
178
+ checkoutPath,
179
+ },
180
+ };
181
+ }
182
+ catch (error) {
183
+ const message = error instanceof Error ? error.message : String(error);
184
+ onProgress?.({
185
+ type: 'failed',
186
+ message,
187
+ });
188
+ cleanup();
189
+ throw error;
190
+ }
191
+ }
@@ -0,0 +1,2 @@
1
+ export declare function renderShareImageSvg(cardText: string): string;
2
+ export declare function writeShareImage(cardText: string, outputPath: string): string;
@@ -0,0 +1,144 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Resvg } from '@resvg/resvg-js';
4
+ const ANSI_RE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g');
5
+ const BASE_FONT_SIZE = 18;
6
+ const LINE_HEIGHT = 28;
7
+ const CHAR_WIDTH = 10;
8
+ const PADDING_X = 32;
9
+ const PADDING_Y = 32;
10
+ const SUPPORTED_OUTPUT_FORMATS = new Set(['png', 'svg']);
11
+ const DEFAULT_FILL = '#eaf2ff';
12
+ const DIM_FILL = '#94a3b8';
13
+ const RED_FILL = '#ef4444';
14
+ const GREEN_FILL = '#22c55e';
15
+ const YELLOW_FILL = '#eab308';
16
+ const CYAN_FILL = '#06b6d4';
17
+ function ansiToFill(code) {
18
+ if (code === '\x1b[0m' || code === '\x1b[39m')
19
+ return DEFAULT_FILL;
20
+ if (code === '\x1b[22m')
21
+ return DEFAULT_FILL;
22
+ if (code === '\x1b[2m')
23
+ return DIM_FILL;
24
+ if (code.includes('31'))
25
+ return RED_FILL;
26
+ if (code.includes('32'))
27
+ return GREEN_FILL;
28
+ if (code.includes('33'))
29
+ return YELLOW_FILL;
30
+ if (code.includes('36'))
31
+ return CYAN_FILL;
32
+ return null;
33
+ }
34
+ function parseAnsiLine(line) {
35
+ const raw = [];
36
+ let fill = DEFAULT_FILL;
37
+ let lastIndex = 0;
38
+ ANSI_RE.lastIndex = 0;
39
+ let match = ANSI_RE.exec(line);
40
+ while (match !== null) {
41
+ const text = line.slice(lastIndex, match.index);
42
+ if (text.length > 0) {
43
+ raw.push({ text, fill });
44
+ }
45
+ const nextFill = ansiToFill(match[0]);
46
+ if (nextFill !== null)
47
+ fill = nextFill;
48
+ lastIndex = match.index + match[0].length;
49
+ match = ANSI_RE.exec(line);
50
+ }
51
+ const tail = line.slice(lastIndex);
52
+ if (tail.length > 0) {
53
+ raw.push({ text: tail, fill });
54
+ }
55
+ // resvg drops whitespace-only tspan elements, so merge them into the
56
+ // preceding segment to keep spaces visible.
57
+ const segments = [];
58
+ for (const seg of raw) {
59
+ if (seg.text.trim() === '' && segments.length > 0) {
60
+ const prev = segments[segments.length - 1];
61
+ segments[segments.length - 1] = {
62
+ text: prev.text + seg.text,
63
+ fill: prev.fill,
64
+ };
65
+ }
66
+ else {
67
+ segments.push(seg);
68
+ }
69
+ }
70
+ return segments;
71
+ }
72
+ function escapeXml(input) {
73
+ return input
74
+ .replaceAll('&', '&amp;')
75
+ .replaceAll('<', '&lt;')
76
+ .replaceAll('>', '&gt;')
77
+ .replaceAll('"', '&quot;')
78
+ .replaceAll("'", '&apos;');
79
+ }
80
+ export function renderShareImageSvg(cardText) {
81
+ const trimmed = cardText.trimEnd();
82
+ const lines = trimmed.split('\n');
83
+ const maxChars = Math.max(1, ...lines.map((line) => line.replace(ANSI_RE, '').length));
84
+ const textBlockWidth = Math.ceil(maxChars * CHAR_WIDTH);
85
+ const textBlockHeight = Math.max(1, lines.length) * LINE_HEIGHT;
86
+ const canvasWidth = textBlockWidth + PADDING_X * 2;
87
+ const canvasHeight = textBlockHeight + PADDING_Y * 2;
88
+ const textX = PADDING_X;
89
+ const textStartY = PADDING_Y + BASE_FONT_SIZE;
90
+ const fontFamily = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace";
91
+ const lineElements = lines
92
+ .map((line, index) => {
93
+ const y = textStartY + index * LINE_HEIGHT;
94
+ const segments = parseAnsiLine(line);
95
+ if (segments.length === 0) {
96
+ return ` <text x="${textX}" y="${y}" fill="${DEFAULT_FILL}" font-family="${fontFamily}" font-size="${BASE_FONT_SIZE}"></text>`;
97
+ }
98
+ const tspans = segments
99
+ .map((seg) => {
100
+ const escaped = escapeXml(seg.text);
101
+ return `<tspan fill="${seg.fill}">${escaped}</tspan>`;
102
+ })
103
+ .join('');
104
+ return ` <text x="${textX}" y="${y}" font-family="${fontFamily}" font-size="${BASE_FONT_SIZE}" xml:space="preserve">${tspans}</text>`;
105
+ })
106
+ .join('\n');
107
+ return [
108
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}" viewBox="0 0 ${canvasWidth} ${canvasHeight}" role="img" aria-label="skill-check share card">`,
109
+ ' <rect width="100%" height="100%" fill="#0d1117"/>',
110
+ lineElements,
111
+ '</svg>',
112
+ '',
113
+ ].join('\n');
114
+ }
115
+ function resolveOutputTarget(outputPath) {
116
+ const resolved = path.resolve(outputPath);
117
+ const ext = path.extname(resolved).toLowerCase().replace('.', '');
118
+ const parsed = path.parse(resolved);
119
+ const format = SUPPORTED_OUTPUT_FORMATS.has(ext) ? ext : 'png';
120
+ const finalPath = format === ext
121
+ ? resolved
122
+ : path.join(parsed.dir, `${parsed.name}.${format}`);
123
+ return { path: finalPath, format: format };
124
+ }
125
+ export function writeShareImage(cardText, outputPath) {
126
+ const target = resolveOutputTarget(outputPath);
127
+ const parent = path.dirname(target.path);
128
+ fs.mkdirSync(parent, { recursive: true });
129
+ try {
130
+ fs.unlinkSync(target.path);
131
+ }
132
+ catch {
133
+ // ignore if file does not exist
134
+ }
135
+ const svg = renderShareImageSvg(cardText);
136
+ if (target.format === 'svg') {
137
+ fs.writeFileSync(target.path, svg, 'utf8');
138
+ return target.path;
139
+ }
140
+ const resvg = new Resvg(svg);
141
+ const pngData = resvg.render().asPng();
142
+ fs.writeFileSync(target.path, pngData);
143
+ return target.path;
144
+ }
@@ -14,7 +14,9 @@ export const bodyRules = [
14
14
  return [
15
15
  {
16
16
  message: `body lines ${lines} exceeds max ${max}`,
17
- suggestion: `Reduce body to ${max} lines or fewer.`,
17
+ suggestion: `Run "npx skill-check split-body <skill-dir-or-file>" to preview section-based extraction into references/*.md, then re-run with "--write". ` +
18
+ `For editorial cleanup, use docs/skills/split-into-references/SKILL.md ` +
19
+ `(https://github.com/thedaviddias/skill-check/blob/main/docs/skills/split-into-references/SKILL.md).`,
18
20
  },
19
21
  ];
20
22
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-check",
3
- "version": "0.1.1",
3
+ "version": "1.1.0",
4
4
  "description": "Linter for agent skill files",
5
5
  "type": "module",
6
6
  "private": false,
@@ -52,8 +52,13 @@
52
52
  "url": "git+https://github.com/thedaviddias/skill-check.git"
53
53
  },
54
54
  "license": "MIT",
55
+ "publishConfig": {
56
+ "provenance": true,
57
+ "access": "public"
58
+ },
55
59
  "dependencies": {
56
60
  "@clack/prompts": "^1.0.1",
61
+ "@resvg/resvg-js": "^2.6.2",
57
62
  "boxen": "^8.0.1",
58
63
  "cli-table3": "^0.6.5",
59
64
  "commander": "^14.0.3",
@@ -69,6 +74,7 @@
69
74
  "@commitlint/config-conventional": "^20.4.2",
70
75
  "@semantic-release/changelog": "^6.0.3",
71
76
  "@semantic-release/git": "^10.0.1",
77
+ "@semantic-release/npm": "^13.1.4",
72
78
  "@types/node": "^25.3.0",
73
79
  "@vitest/coverage-v8": "^4.0.18",
74
80
  "commitlint": "^20.4.2",