@skillguard/cli 0.2.1 → 0.4.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/CHANGELOG.md +16 -0
- package/README.md +77 -5
- package/dist/cli.js +103 -15
- package/dist/commands/gate.d.ts +14 -0
- package/dist/commands/gate.js +17 -0
- package/dist/commands/limits.d.ts +8 -0
- package/dist/commands/limits.js +98 -0
- package/dist/commands/scan.js +20 -9
- package/dist/commands/workflow.d.ts +21 -0
- package/dist/commands/workflow.js +143 -0
- package/dist/lib/api.d.ts +40 -0
- package/dist/lib/api.js +127 -0
- package/dist/lib/help.d.ts +2 -0
- package/dist/lib/help.js +240 -0
- package/dist/lib/version.d.ts +1 -0
- package/dist/lib/version.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- Added `skillguard workflow` command to generate ready-to-use GitHub Actions CI gate workflow
|
|
6
|
+
- Added `--auto-gh-push` option for workflow command (`git add/commit/push`)
|
|
7
|
+
- Added `skillguard help <command>` and `skillguard man <command>` detailed help pages
|
|
8
|
+
- Added `--workflow` alias for fast workflow generation
|
|
9
|
+
- Updated CLI/web workflow snippets to default to zero-secret gate flow
|
|
10
|
+
|
|
11
|
+
## 0.3.0
|
|
12
|
+
|
|
13
|
+
- Added `skillguard gate` command for deterministic CI gating (`0` pass, `1` fail)
|
|
14
|
+
- Added `skillguard limits` command with human and `--json` output
|
|
15
|
+
- Added anonymous scan fallback when no API key is configured
|
|
16
|
+
- Added CLI telemetry headers (`X-SkillGuard-Source`, `X-SkillGuard-CI`)
|
|
17
|
+
- Added support for backend anonymous limits endpoint
|
|
18
|
+
|
|
3
19
|
## 0.2.0
|
|
4
20
|
|
|
5
21
|
- Added `skillguard scan` without path argument (defaults to recursive scan from current directory)
|
package/README.md
CHANGED
|
@@ -8,12 +8,26 @@ Security scanner CLI for AI agent `SKILL.md` files.
|
|
|
8
8
|
npx @skillguard/cli scan SKILL.md
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
No API key configured? CLI falls back to anonymous scanning (rate-limited).
|
|
12
|
+
|
|
11
13
|
### Scan all skills in current repo
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
16
|
npx @skillguard/cli scan
|
|
15
17
|
```
|
|
16
18
|
|
|
19
|
+
### Generate CI workflow (no secrets required)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @skillguard/cli workflow
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Command help (man-like)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @skillguard/cli help scan
|
|
29
|
+
```
|
|
30
|
+
|
|
17
31
|
## Setup
|
|
18
32
|
|
|
19
33
|
```bash
|
|
@@ -46,6 +60,24 @@ npx @skillguard/cli scan ./skills --fail-on warning
|
|
|
46
60
|
npx @skillguard/cli scan ./skills --json > skillguard-report.json
|
|
47
61
|
```
|
|
48
62
|
|
|
63
|
+
### CI gate (recommended)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx @skillguard/cli gate ./skills
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Check remaining limits
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx @skillguard/cli limits
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Generate and push workflow in one command
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx @skillguard/cli workflow --auto-gh-push
|
|
79
|
+
```
|
|
80
|
+
|
|
49
81
|
### Worst-score CI exit code (recommended)
|
|
50
82
|
|
|
51
83
|
```bash
|
|
@@ -74,6 +106,33 @@ npx @skillguard/cli verify ./SKILL.md
|
|
|
74
106
|
- `--skip-node-modules` (default: enabled)
|
|
75
107
|
- `--scan-all` (disable skip filters, scans everything recursively)
|
|
76
108
|
|
|
109
|
+
### gate
|
|
110
|
+
|
|
111
|
+
- same options as `scan`
|
|
112
|
+
- defaults to CI gate behavior (`--fail-on warning` in threshold mode)
|
|
113
|
+
|
|
114
|
+
### limits
|
|
115
|
+
|
|
116
|
+
- `--json`
|
|
117
|
+
- `--timeout <ms>` (default: `30000`)
|
|
118
|
+
- `--base-url <url>` (default: `https://skillguard.ai`)
|
|
119
|
+
- `--api-key <key>` (optional)
|
|
120
|
+
- `--no-color`
|
|
121
|
+
|
|
122
|
+
### workflow
|
|
123
|
+
|
|
124
|
+
- `--path <file>` (default: `.github/workflows/skillguard.yml`)
|
|
125
|
+
- `--scan-path <path>` (default: `.`)
|
|
126
|
+
- `--fail-on <safe|warning|dangerous>` (default: `warning`)
|
|
127
|
+
- `--print` (print yaml, do not write file)
|
|
128
|
+
- `--force` (overwrite existing workflow file)
|
|
129
|
+
- `--auto-gh-push` (git add/commit/push after write)
|
|
130
|
+
|
|
131
|
+
### help / man
|
|
132
|
+
|
|
133
|
+
- `help [command]`
|
|
134
|
+
- `man [command]`
|
|
135
|
+
|
|
77
136
|
### verify
|
|
78
137
|
|
|
79
138
|
- `--json`
|
|
@@ -92,6 +151,13 @@ npx @skillguard/cli verify ./SKILL.md
|
|
|
92
151
|
- `verify`:
|
|
93
152
|
- `0` signature valid
|
|
94
153
|
- `1` invalid/tampered/expired signature
|
|
154
|
+
- `limits`:
|
|
155
|
+
- `0` success
|
|
156
|
+
- `gate`:
|
|
157
|
+
- `0` below threshold
|
|
158
|
+
- `1` threshold exceeded
|
|
159
|
+
- `workflow`:
|
|
160
|
+
- `0` success
|
|
95
161
|
- all commands:
|
|
96
162
|
- `4` usage/input/config error
|
|
97
163
|
- `5` network/API/runtime external failure
|
|
@@ -99,8 +165,16 @@ npx @skillguard/cli verify ./SKILL.md
|
|
|
99
165
|
## GitHub Actions example
|
|
100
166
|
|
|
101
167
|
```yaml
|
|
102
|
-
name:
|
|
103
|
-
on:
|
|
168
|
+
name: SkillGuard Gate
|
|
169
|
+
on:
|
|
170
|
+
pull_request:
|
|
171
|
+
paths:
|
|
172
|
+
- '**/SKILL.md'
|
|
173
|
+
- '**/skill.md'
|
|
174
|
+
push:
|
|
175
|
+
paths:
|
|
176
|
+
- '**/SKILL.md'
|
|
177
|
+
- '**/skill.md'
|
|
104
178
|
|
|
105
179
|
jobs:
|
|
106
180
|
scan:
|
|
@@ -110,9 +184,7 @@ jobs:
|
|
|
110
184
|
- uses: actions/setup-node@v4
|
|
111
185
|
with:
|
|
112
186
|
node-version: '20'
|
|
113
|
-
- run: npx @skillguard/cli
|
|
114
|
-
env:
|
|
115
|
-
SKILLGUARD_API_KEY: ${{ secrets.SKILLGUARD_API_KEY }}
|
|
187
|
+
- run: npx @skillguard/cli gate . --fail-on warning
|
|
116
188
|
```
|
|
117
189
|
|
|
118
190
|
More info: [skillguard.ai](https://skillguard.ai)
|
package/dist/cli.js
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { runInit } from './commands/init.js';
|
|
4
|
+
import { runGate } from './commands/gate.js';
|
|
5
|
+
import { runLimits } from './commands/limits.js';
|
|
4
6
|
import { runScan } from './commands/scan.js';
|
|
5
7
|
import { runVerify } from './commands/verify.js';
|
|
6
|
-
|
|
8
|
+
import { runWorkflow } from './commands/workflow.js';
|
|
9
|
+
import { renderGeneralHelp, renderTopicHelp } from './lib/help.js';
|
|
10
|
+
import { CLI_VERSION } from './lib/version.js';
|
|
11
|
+
const VERSION = CLI_VERSION;
|
|
7
12
|
function printUsage() {
|
|
8
|
-
console.log(
|
|
9
|
-
|
|
10
|
-
Usage:
|
|
11
|
-
skillguard init [--api-key <key>] [--base-url <url>]
|
|
12
|
-
skillguard scan [path] [--json] [--fail-on <safe|warning|dangerous>] [--timeout <ms>] [--base-url <url>] [--api-key <key>] [--dry-run] [--quiet] [--no-color] [--skip-node-modules] [--scan-all]
|
|
13
|
-
skillguard verify <path> [--json] [--timeout <ms>] [--base-url <url>]
|
|
14
|
-
|
|
15
|
-
Options:
|
|
16
|
-
--help Show help
|
|
17
|
-
--version Show version
|
|
18
|
-
`);
|
|
13
|
+
console.log(renderGeneralHelp(VERSION));
|
|
19
14
|
}
|
|
20
15
|
function toPositiveInteger(raw, fallback) {
|
|
21
16
|
if (!raw) {
|
|
@@ -53,6 +48,11 @@ function parseShared(args) {
|
|
|
53
48
|
timeout: { type: 'string' },
|
|
54
49
|
'base-url': { type: 'string' },
|
|
55
50
|
'api-key': { type: 'string' },
|
|
51
|
+
path: { type: 'string' },
|
|
52
|
+
'scan-path': { type: 'string' },
|
|
53
|
+
print: { type: 'boolean' },
|
|
54
|
+
force: { type: 'boolean' },
|
|
55
|
+
'auto-gh-push': { type: 'boolean' },
|
|
56
56
|
},
|
|
57
57
|
strict: false,
|
|
58
58
|
});
|
|
@@ -66,7 +66,12 @@ function isFailOnExplicit(args) {
|
|
|
66
66
|
}
|
|
67
67
|
async function run() {
|
|
68
68
|
const argv = process.argv.slice(2);
|
|
69
|
-
|
|
69
|
+
let command = argv[0];
|
|
70
|
+
let commandArgs = argv.slice(1);
|
|
71
|
+
if (command === '--workflow') {
|
|
72
|
+
command = 'workflow';
|
|
73
|
+
commandArgs = argv.slice(1);
|
|
74
|
+
}
|
|
70
75
|
if (!command || command === '--help' || command === '-h') {
|
|
71
76
|
printUsage();
|
|
72
77
|
return 0;
|
|
@@ -75,13 +80,33 @@ async function run() {
|
|
|
75
80
|
console.log(VERSION);
|
|
76
81
|
return 0;
|
|
77
82
|
}
|
|
78
|
-
|
|
83
|
+
if (command === 'help' || command === 'man') {
|
|
84
|
+
const topic = argv[1];
|
|
85
|
+
if (!topic) {
|
|
86
|
+
printUsage();
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
const helpText = renderTopicHelp(topic);
|
|
90
|
+
if (!helpText) {
|
|
91
|
+
console.error(`Unknown help topic: ${topic}`);
|
|
92
|
+
return 4;
|
|
93
|
+
}
|
|
94
|
+
console.log(helpText);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
const { options, positionals } = parseShared(commandArgs);
|
|
79
98
|
if (options.version) {
|
|
80
99
|
console.log(VERSION);
|
|
81
100
|
return 0;
|
|
82
101
|
}
|
|
83
102
|
if (options.help) {
|
|
84
|
-
|
|
103
|
+
const topicHelp = renderTopicHelp(command);
|
|
104
|
+
if (topicHelp) {
|
|
105
|
+
console.log(topicHelp);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
printUsage();
|
|
109
|
+
}
|
|
85
110
|
return 0;
|
|
86
111
|
}
|
|
87
112
|
if (command === 'init') {
|
|
@@ -119,6 +144,69 @@ async function run() {
|
|
|
119
144
|
};
|
|
120
145
|
return await runScan(pathArg, scanOptions);
|
|
121
146
|
}
|
|
147
|
+
if (command === 'gate') {
|
|
148
|
+
const pathArg = positionals[0];
|
|
149
|
+
let parsedTimeout;
|
|
150
|
+
let failOn;
|
|
151
|
+
try {
|
|
152
|
+
parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
|
|
153
|
+
failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.error(error.message);
|
|
157
|
+
return 4;
|
|
158
|
+
}
|
|
159
|
+
const gateOptions = {
|
|
160
|
+
json: options.json === true,
|
|
161
|
+
failOn,
|
|
162
|
+
scanAll: options['scan-all'] === true,
|
|
163
|
+
skipNodeModules: options['scan-all'] === true ? false : options['skip-node-modules'] !== false,
|
|
164
|
+
timeoutMs: parsedTimeout,
|
|
165
|
+
baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
|
|
166
|
+
apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
|
|
167
|
+
dryRun: options['dry-run'] === true,
|
|
168
|
+
quiet: options.quiet === true,
|
|
169
|
+
noColor: options['no-color'] === true,
|
|
170
|
+
};
|
|
171
|
+
return await runGate(pathArg, gateOptions);
|
|
172
|
+
}
|
|
173
|
+
if (command === 'limits') {
|
|
174
|
+
let parsedTimeout;
|
|
175
|
+
try {
|
|
176
|
+
parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
console.error(error.message);
|
|
180
|
+
return 4;
|
|
181
|
+
}
|
|
182
|
+
const limitsOptions = {
|
|
183
|
+
json: options.json === true,
|
|
184
|
+
timeoutMs: parsedTimeout,
|
|
185
|
+
baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
|
|
186
|
+
apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
|
|
187
|
+
noColor: options['no-color'] === true,
|
|
188
|
+
};
|
|
189
|
+
return await runLimits(limitsOptions);
|
|
190
|
+
}
|
|
191
|
+
if (command === 'workflow') {
|
|
192
|
+
let failOn;
|
|
193
|
+
try {
|
|
194
|
+
failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
console.error(error.message);
|
|
198
|
+
return 4;
|
|
199
|
+
}
|
|
200
|
+
const workflowOptions = {
|
|
201
|
+
outputPath: typeof options.path === 'string' ? options.path : undefined,
|
|
202
|
+
scanPath: typeof options['scan-path'] === 'string' ? options['scan-path'] : '.',
|
|
203
|
+
failOn,
|
|
204
|
+
print: options.print === true,
|
|
205
|
+
force: options.force === true,
|
|
206
|
+
autoGhPush: options['auto-gh-push'] === true,
|
|
207
|
+
};
|
|
208
|
+
return await runWorkflow(workflowOptions);
|
|
209
|
+
}
|
|
122
210
|
if (command === 'verify') {
|
|
123
211
|
const pathArg = positionals[0];
|
|
124
212
|
if (!pathArg) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ScanScore } from '../lib/api.js';
|
|
2
|
+
export interface GateOptions {
|
|
3
|
+
json: boolean;
|
|
4
|
+
failOn?: ScanScore;
|
|
5
|
+
scanAll: boolean;
|
|
6
|
+
skipNodeModules: boolean;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
dryRun: boolean;
|
|
11
|
+
quiet: boolean;
|
|
12
|
+
noColor: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function runGate(inputPath: string | undefined, options: GateOptions): Promise<number>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runScan } from './scan.js';
|
|
2
|
+
export async function runGate(inputPath, options) {
|
|
3
|
+
const scanOptions = {
|
|
4
|
+
json: options.json,
|
|
5
|
+
failOn: options.failOn || 'warning',
|
|
6
|
+
failOnExplicit: true,
|
|
7
|
+
scanAll: options.scanAll,
|
|
8
|
+
skipNodeModules: options.skipNodeModules,
|
|
9
|
+
timeoutMs: options.timeoutMs,
|
|
10
|
+
baseUrl: options.baseUrl,
|
|
11
|
+
apiKey: options.apiKey,
|
|
12
|
+
dryRun: options.dryRun,
|
|
13
|
+
quiet: options.quiet,
|
|
14
|
+
noColor: options.noColor,
|
|
15
|
+
};
|
|
16
|
+
return runScan(inputPath, scanOptions);
|
|
17
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { fetchLimits } from '../lib/api.js';
|
|
2
|
+
import { normalizeBaseUrl, readConfig, resolveApiKey } from '../lib/config.js';
|
|
3
|
+
import { CLI_VERSION } from '../lib/version.js';
|
|
4
|
+
function renderHuman(response) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
const mode = typeof response.mode === 'string' ? response.mode : 'unknown';
|
|
7
|
+
lines.push(`Mode: ${mode}`);
|
|
8
|
+
if (typeof response.tokenBalance === 'number') {
|
|
9
|
+
lines.push(`Token balance: ${response.tokenBalance}`);
|
|
10
|
+
}
|
|
11
|
+
if (response.ownerTrial && typeof response.ownerTrial === 'object') {
|
|
12
|
+
const trial = response.ownerTrial;
|
|
13
|
+
lines.push('Owner trial:');
|
|
14
|
+
lines.push(` scans: ${Number(trial.scansUsed || 0)} / ${Number(trial.scansLimit || 0)}`);
|
|
15
|
+
lines.push(` remaining: ${Number(trial.remainingScans || 0)}`);
|
|
16
|
+
lines.push(` active: ${trial.active === true ? 'yes' : 'no'}`);
|
|
17
|
+
if (typeof trial.expiresAt === 'string' && trial.expiresAt) {
|
|
18
|
+
lines.push(` expires: ${trial.expiresAt}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (response.apiKeyQuota && typeof response.apiKeyQuota === 'object') {
|
|
22
|
+
const quota = response.apiKeyQuota;
|
|
23
|
+
lines.push('API key quota:');
|
|
24
|
+
lines.push(` key type: ${String(quota.keyType || 'standard')}`);
|
|
25
|
+
lines.push(` unlimited: ${quota.unlimited === true ? 'yes' : 'no'}`);
|
|
26
|
+
lines.push(` scans used: ${Number(quota.scansUsed || 0)}`);
|
|
27
|
+
if (typeof quota.maxScans === 'number') {
|
|
28
|
+
lines.push(` max scans: ${quota.maxScans}`);
|
|
29
|
+
}
|
|
30
|
+
if (typeof quota.remainingScans === 'number') {
|
|
31
|
+
lines.push(` remaining: ${quota.remainingScans}`);
|
|
32
|
+
}
|
|
33
|
+
lines.push(` active: ${quota.active === true ? 'yes' : 'no'}`);
|
|
34
|
+
if (typeof quota.expiresAt === 'string' && quota.expiresAt) {
|
|
35
|
+
lines.push(` expires: ${quota.expiresAt}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (response.anonymousQuota && typeof response.anonymousQuota === 'object') {
|
|
39
|
+
const quota = response.anonymousQuota;
|
|
40
|
+
lines.push('Anonymous quota:');
|
|
41
|
+
lines.push(` scans: ${Number(quota.scansUsed || 0)} / ${Number(quota.maxScans || 0)}`);
|
|
42
|
+
lines.push(` remaining: ${Number(quota.remainingScans || 0)}`);
|
|
43
|
+
if (typeof quota.resetAt === 'string' && quota.resetAt) {
|
|
44
|
+
lines.push(` reset at: ${quota.resetAt}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
}
|
|
49
|
+
export async function runLimits(options) {
|
|
50
|
+
let baseUrl;
|
|
51
|
+
try {
|
|
52
|
+
baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.error(error.message);
|
|
56
|
+
return 4;
|
|
57
|
+
}
|
|
58
|
+
const hasApiKeyFlag = typeof options.apiKey === 'string' && options.apiKey.trim().length > 0;
|
|
59
|
+
const hasApiKeyEnv = typeof process.env.SKILLGUARD_API_KEY === 'string' && process.env.SKILLGUARD_API_KEY.trim().length > 0;
|
|
60
|
+
let config = null;
|
|
61
|
+
if (!hasApiKeyFlag && !hasApiKeyEnv) {
|
|
62
|
+
try {
|
|
63
|
+
config = await readConfig();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.error(error.message);
|
|
67
|
+
return 4;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const resolvedApiKey = resolveApiKey({
|
|
71
|
+
apiKeyFlag: options.apiKey,
|
|
72
|
+
env: process.env,
|
|
73
|
+
config,
|
|
74
|
+
});
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await fetchLimits({
|
|
78
|
+
baseUrl,
|
|
79
|
+
timeoutMs: options.timeoutMs,
|
|
80
|
+
apiKey: resolvedApiKey?.apiKey,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
console.error(error.message);
|
|
85
|
+
return 5;
|
|
86
|
+
}
|
|
87
|
+
if (options.json) {
|
|
88
|
+
console.log(JSON.stringify({
|
|
89
|
+
cli_version: CLI_VERSION,
|
|
90
|
+
fetched_at: new Date().toISOString(),
|
|
91
|
+
...response,
|
|
92
|
+
}, null, 2));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.log(renderHuman(response));
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
package/dist/commands/scan.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFile, readdir, realpath } from 'node:fs/promises';
|
|
2
2
|
import { basename, resolve, sep } from 'node:path';
|
|
3
|
-
import { normalizeFindings, scanContent, toScanScore } from '../lib/api.js';
|
|
3
|
+
import { normalizeFindings, scanContent, scanContentAnonymous, toScanScore } from '../lib/api.js';
|
|
4
4
|
import { normalizeBaseUrl, readConfig, resolveApiKey } from '../lib/config.js';
|
|
5
5
|
import { renderSingleScan, renderSummary, shouldUseColor, summarizeScores } from '../lib/format.js';
|
|
6
|
+
import { CLI_VERSION } from '../lib/version.js';
|
|
6
7
|
const FAIL_LEVELS = {
|
|
7
8
|
safe: 0,
|
|
8
9
|
warning: 1,
|
|
@@ -164,22 +165,32 @@ export async function runScan(inputPath, options) {
|
|
|
164
165
|
env: process.env,
|
|
165
166
|
config,
|
|
166
167
|
});
|
|
167
|
-
|
|
168
|
+
const useAnonymous = !resolvedApiKey;
|
|
169
|
+
if (!useAnonymous && !resolvedApiKey) {
|
|
168
170
|
console.error("No API key found. Run 'skillguard init' or set SKILLGUARD_API_KEY.");
|
|
169
171
|
return 4;
|
|
170
172
|
}
|
|
171
173
|
const color = shouldUseColor(options.noColor);
|
|
172
174
|
const results = [];
|
|
175
|
+
if (useAnonymous && !options.quiet && !options.json) {
|
|
176
|
+
console.log('Scanning anonymously (limited). Add API key for higher limits.');
|
|
177
|
+
}
|
|
173
178
|
for (const filePath of targets) {
|
|
174
179
|
const content = await readFile(filePath, 'utf8');
|
|
175
180
|
let apiResponse;
|
|
176
181
|
try {
|
|
177
|
-
apiResponse =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
apiResponse = useAnonymous
|
|
183
|
+
? await scanContentAnonymous({
|
|
184
|
+
baseUrl,
|
|
185
|
+
content,
|
|
186
|
+
timeoutMs: options.timeoutMs,
|
|
187
|
+
})
|
|
188
|
+
: await scanContent({
|
|
189
|
+
baseUrl,
|
|
190
|
+
apiKey: resolvedApiKey.apiKey,
|
|
191
|
+
content,
|
|
192
|
+
timeoutMs: options.timeoutMs,
|
|
193
|
+
});
|
|
183
194
|
}
|
|
184
195
|
catch (error) {
|
|
185
196
|
console.error(error.message);
|
|
@@ -208,7 +219,7 @@ export async function runScan(inputPath, options) {
|
|
|
208
219
|
if (options.json) {
|
|
209
220
|
const summary = summarizeScores(results);
|
|
210
221
|
console.log(JSON.stringify({
|
|
211
|
-
cli_version:
|
|
222
|
+
cli_version: CLI_VERSION,
|
|
212
223
|
scanned_at: new Date().toISOString(),
|
|
213
224
|
mode,
|
|
214
225
|
worst_score: worstScore,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ScanScore } from '../lib/api.js';
|
|
2
|
+
export interface WorkflowOptions {
|
|
3
|
+
outputPath?: string;
|
|
4
|
+
scanPath: string;
|
|
5
|
+
failOn: ScanScore;
|
|
6
|
+
print: boolean;
|
|
7
|
+
force: boolean;
|
|
8
|
+
autoGhPush: boolean;
|
|
9
|
+
commitMessage?: string;
|
|
10
|
+
}
|
|
11
|
+
interface GitCommandResult {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
status: number;
|
|
15
|
+
}
|
|
16
|
+
type GitRunner = (args: string[]) => GitCommandResult;
|
|
17
|
+
export declare function runWorkflow(options: WorkflowOptions, runtime?: {
|
|
18
|
+
cwd?: string;
|
|
19
|
+
gitRunner?: GitRunner;
|
|
20
|
+
}): Promise<number>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { mkdir, access, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
const DEFAULT_WORKFLOW_PATH = '.github/workflows/skillguard.yml';
|
|
6
|
+
const DEFAULT_COMMIT_MESSAGE = 'chore(ci): add skillguard workflow';
|
|
7
|
+
function buildWorkflowYaml(input) {
|
|
8
|
+
const scanPath = input.scanPath.trim() || '.';
|
|
9
|
+
return [
|
|
10
|
+
'name: SkillGuard Gate',
|
|
11
|
+
'on:',
|
|
12
|
+
' pull_request:',
|
|
13
|
+
' paths:',
|
|
14
|
+
" - '**/SKILL.md'",
|
|
15
|
+
" - '**/skill.md'",
|
|
16
|
+
' push:',
|
|
17
|
+
' paths:',
|
|
18
|
+
" - '**/SKILL.md'",
|
|
19
|
+
" - '**/skill.md'",
|
|
20
|
+
'',
|
|
21
|
+
'jobs:',
|
|
22
|
+
' scan:',
|
|
23
|
+
' runs-on: ubuntu-latest',
|
|
24
|
+
' steps:',
|
|
25
|
+
' - uses: actions/checkout@v4',
|
|
26
|
+
' - uses: actions/setup-node@v4',
|
|
27
|
+
' with:',
|
|
28
|
+
" node-version: '20'",
|
|
29
|
+
' - name: SkillGuard scan',
|
|
30
|
+
` run: npx @skillguard/cli gate ${scanPath} --fail-on ${input.failOn}`,
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
function isCommandSafePath(value) {
|
|
34
|
+
return /^[./a-zA-Z0-9_-]+$/.test(value);
|
|
35
|
+
}
|
|
36
|
+
async function fileExists(path) {
|
|
37
|
+
try {
|
|
38
|
+
await access(path, fsConstants.F_OK);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function createDefaultGitRunner(cwd) {
|
|
46
|
+
return (args) => {
|
|
47
|
+
const result = spawnSync('git', args, {
|
|
48
|
+
cwd,
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
if (result.error) {
|
|
53
|
+
throw new Error('Git command failed.');
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
57
|
+
stderr: typeof result.stderr === 'string' ? result.stderr : '',
|
|
58
|
+
status: typeof result.status === 'number' ? result.status : 1,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function ensureGitSuccess(result, fallbackMessage) {
|
|
63
|
+
if (result.status === 0) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const stderr = result.stderr.trim();
|
|
67
|
+
if (stderr) {
|
|
68
|
+
throw new Error(stderr);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(fallbackMessage);
|
|
71
|
+
}
|
|
72
|
+
function resolveWorkflowPath(cwd, outputPath) {
|
|
73
|
+
return resolve(cwd, outputPath || DEFAULT_WORKFLOW_PATH);
|
|
74
|
+
}
|
|
75
|
+
function commitAndPushWorkflow(input) {
|
|
76
|
+
const git = input.gitRunner || createDefaultGitRunner(input.cwd);
|
|
77
|
+
const repoRootResult = git(['rev-parse', '--show-toplevel']);
|
|
78
|
+
ensureGitSuccess(repoRootResult, 'Not a git repository.');
|
|
79
|
+
const repoRoot = repoRootResult.stdout.trim();
|
|
80
|
+
const relativePath = relative(repoRoot, input.workflowPath);
|
|
81
|
+
if (!relativePath || relativePath.startsWith('..')) {
|
|
82
|
+
throw new Error('Workflow path must be inside the current git repository.');
|
|
83
|
+
}
|
|
84
|
+
ensureGitSuccess(git(['add', '--', relativePath]), 'Could not stage workflow file.');
|
|
85
|
+
const stagedResult = git(['diff', '--cached', '--name-only', '--', relativePath]);
|
|
86
|
+
ensureGitSuccess(stagedResult, 'Could not inspect staged changes.');
|
|
87
|
+
if (!stagedResult.stdout.trim()) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
ensureGitSuccess(git(['commit', '-m', input.commitMessage, '--', relativePath]), 'Could not create commit.');
|
|
91
|
+
ensureGitSuccess(git(['push']), 'Could not push commit.');
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
export async function runWorkflow(options, runtime) {
|
|
95
|
+
const cwd = runtime?.cwd || process.cwd();
|
|
96
|
+
const scanPath = (options.scanPath || '.').trim() || '.';
|
|
97
|
+
if (!isCommandSafePath(scanPath)) {
|
|
98
|
+
console.error('Invalid --scan-path value. Use simple relative paths only.');
|
|
99
|
+
return 4;
|
|
100
|
+
}
|
|
101
|
+
if (options.print && options.autoGhPush) {
|
|
102
|
+
console.error('Cannot use --auto-gh-push with --print.');
|
|
103
|
+
return 4;
|
|
104
|
+
}
|
|
105
|
+
const yaml = buildWorkflowYaml({
|
|
106
|
+
scanPath,
|
|
107
|
+
failOn: options.failOn,
|
|
108
|
+
});
|
|
109
|
+
if (options.print) {
|
|
110
|
+
console.log(yaml);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
const workflowPath = resolveWorkflowPath(cwd, options.outputPath);
|
|
114
|
+
const exists = await fileExists(workflowPath);
|
|
115
|
+
if (exists && !options.force) {
|
|
116
|
+
console.error(`Workflow file already exists: ${workflowPath}. Use --force to overwrite.`);
|
|
117
|
+
return 4;
|
|
118
|
+
}
|
|
119
|
+
await mkdir(dirname(workflowPath), { recursive: true });
|
|
120
|
+
await writeFile(workflowPath, `${yaml}\n`, 'utf8');
|
|
121
|
+
console.log(`Workflow written to ${workflowPath}`);
|
|
122
|
+
if (!options.autoGhPush) {
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const pushed = commitAndPushWorkflow({
|
|
127
|
+
cwd,
|
|
128
|
+
workflowPath,
|
|
129
|
+
commitMessage: (options.commitMessage || DEFAULT_COMMIT_MESSAGE).trim() || DEFAULT_COMMIT_MESSAGE,
|
|
130
|
+
gitRunner: runtime?.gitRunner,
|
|
131
|
+
});
|
|
132
|
+
if (!pushed) {
|
|
133
|
+
console.log('Workflow already up to date. No commit created.');
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.error(`Auto push failed: ${error.message}`);
|
|
139
|
+
return 5;
|
|
140
|
+
}
|
|
141
|
+
console.log('Workflow committed and pushed.');
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -14,6 +14,34 @@ export interface ScanApiResponse {
|
|
|
14
14
|
signedSkillContent?: string;
|
|
15
15
|
[key: string]: unknown;
|
|
16
16
|
}
|
|
17
|
+
export interface LimitsApiResponse {
|
|
18
|
+
mode?: 'anonymous' | 'owner' | 'agent';
|
|
19
|
+
tokenBalance?: number;
|
|
20
|
+
ownerTrial?: {
|
|
21
|
+
scansLimit?: number;
|
|
22
|
+
scansUsed?: number;
|
|
23
|
+
remainingScans?: number;
|
|
24
|
+
expiresAt?: string | null;
|
|
25
|
+
active?: boolean;
|
|
26
|
+
};
|
|
27
|
+
apiKeyQuota?: {
|
|
28
|
+
keyType?: 'standard' | 'trial';
|
|
29
|
+
maxScans?: number | null;
|
|
30
|
+
scansUsed?: number;
|
|
31
|
+
remainingScans?: number | null;
|
|
32
|
+
expiresAt?: string | null;
|
|
33
|
+
unlimited?: boolean;
|
|
34
|
+
active?: boolean;
|
|
35
|
+
};
|
|
36
|
+
anonymousQuota?: {
|
|
37
|
+
windowMs?: number;
|
|
38
|
+
maxScans?: number;
|
|
39
|
+
scansUsed?: number;
|
|
40
|
+
remainingScans?: number;
|
|
41
|
+
resetAt?: string;
|
|
42
|
+
};
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
17
45
|
export interface SanitizedApiError {
|
|
18
46
|
message: string;
|
|
19
47
|
exitCode: 2 | 3;
|
|
@@ -26,6 +54,18 @@ export declare function scanContent(input: {
|
|
|
26
54
|
timeoutMs: number;
|
|
27
55
|
fetchImpl?: typeof fetch;
|
|
28
56
|
}): Promise<ScanApiResponse>;
|
|
57
|
+
export declare function scanContentAnonymous(input: {
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
content: string;
|
|
60
|
+
timeoutMs: number;
|
|
61
|
+
fetchImpl?: typeof fetch;
|
|
62
|
+
}): Promise<ScanApiResponse>;
|
|
63
|
+
export declare function fetchLimits(input: {
|
|
64
|
+
baseUrl: string;
|
|
65
|
+
timeoutMs: number;
|
|
66
|
+
apiKey?: string;
|
|
67
|
+
fetchImpl?: typeof fetch;
|
|
68
|
+
}): Promise<LimitsApiResponse>;
|
|
29
69
|
export declare function fetchJwks(input: {
|
|
30
70
|
baseUrl: string;
|
|
31
71
|
timeoutMs: number;
|
package/dist/lib/api.js
CHANGED
|
@@ -58,6 +58,47 @@ function mapStatusMessage(status) {
|
|
|
58
58
|
}
|
|
59
59
|
return 'SkillGuard API request failed.';
|
|
60
60
|
}
|
|
61
|
+
function mapLimitsStatusMessage(status) {
|
|
62
|
+
if (status === 401) {
|
|
63
|
+
return "Invalid API key. Run 'skillguard init' or set SKILLGUARD_API_KEY.";
|
|
64
|
+
}
|
|
65
|
+
if (status === 403) {
|
|
66
|
+
return 'Access denied for this API key.';
|
|
67
|
+
}
|
|
68
|
+
if (status === 429) {
|
|
69
|
+
return 'Rate limit exceeded. Wait and retry.';
|
|
70
|
+
}
|
|
71
|
+
if (status >= 500) {
|
|
72
|
+
return 'SkillGuard API error. Try again later.';
|
|
73
|
+
}
|
|
74
|
+
return 'SkillGuard limits request failed.';
|
|
75
|
+
}
|
|
76
|
+
function detectCiSource(env = process.env) {
|
|
77
|
+
if (env.GITHUB_ACTIONS === 'true')
|
|
78
|
+
return 'github-actions';
|
|
79
|
+
if (env.GITLAB_CI === 'true')
|
|
80
|
+
return 'gitlab';
|
|
81
|
+
if (env.BUILDKITE === 'true')
|
|
82
|
+
return 'buildkite';
|
|
83
|
+
if (env.CIRCLECI === 'true')
|
|
84
|
+
return 'circleci';
|
|
85
|
+
if (env.JENKINS_URL)
|
|
86
|
+
return 'jenkins';
|
|
87
|
+
if (env.TF_BUILD === 'True' || env.AZURE_HTTP_USER_AGENT)
|
|
88
|
+
return 'azure-pipelines';
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function buildCliHeaders(extra = {}) {
|
|
92
|
+
const headers = {
|
|
93
|
+
'X-SkillGuard-Source': 'cli',
|
|
94
|
+
...extra,
|
|
95
|
+
};
|
|
96
|
+
const ciSource = detectCiSource();
|
|
97
|
+
if (ciSource) {
|
|
98
|
+
headers['X-SkillGuard-CI'] = ciSource;
|
|
99
|
+
}
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
61
102
|
export async function scanContent(input) {
|
|
62
103
|
const fetchFn = input.fetchImpl || fetch;
|
|
63
104
|
const endpoint = `${input.baseUrl}/api/v1/scan`;
|
|
@@ -66,6 +107,7 @@ export async function scanContent(input) {
|
|
|
66
107
|
const response = await fetchWithRetry(fetchFn, endpoint, {
|
|
67
108
|
method: 'POST',
|
|
68
109
|
headers: {
|
|
110
|
+
...buildCliHeaders(),
|
|
69
111
|
'X-API-Key': input.apiKey,
|
|
70
112
|
'Content-Type': 'application/json',
|
|
71
113
|
},
|
|
@@ -101,6 +143,91 @@ export async function scanContent(input) {
|
|
|
101
143
|
timeout.cancel();
|
|
102
144
|
}
|
|
103
145
|
}
|
|
146
|
+
export async function scanContentAnonymous(input) {
|
|
147
|
+
const fetchFn = input.fetchImpl || fetch;
|
|
148
|
+
const endpoint = `${input.baseUrl}/api/v1/scan/anonymous`;
|
|
149
|
+
const timeout = withTimeout(input.timeoutMs);
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetchWithRetry(fetchFn, endpoint, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: {
|
|
154
|
+
...buildCliHeaders(),
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify({ content: input.content }),
|
|
158
|
+
signal: timeout.signal,
|
|
159
|
+
}, 1);
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new Error(mapStatusMessage(response.status));
|
|
162
|
+
}
|
|
163
|
+
const parsed = await parseJsonSafe(response);
|
|
164
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
165
|
+
throw new Error('Unexpected API response.');
|
|
166
|
+
}
|
|
167
|
+
return parsed;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const typed = error;
|
|
171
|
+
if (typed.name === 'AbortError') {
|
|
172
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
173
|
+
}
|
|
174
|
+
if (typed.message === 'Unexpected API response.') {
|
|
175
|
+
throw typed;
|
|
176
|
+
}
|
|
177
|
+
if (typed.message.includes('Rate limit exceeded.') ||
|
|
178
|
+
typed.message.includes('SkillGuard API error.') ||
|
|
179
|
+
typed.message.includes('SkillGuard API request failed.')) {
|
|
180
|
+
throw typed;
|
|
181
|
+
}
|
|
182
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
timeout.cancel();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export async function fetchLimits(input) {
|
|
189
|
+
const fetchFn = input.fetchImpl || fetch;
|
|
190
|
+
const endpoint = `${input.baseUrl}/api/v1/limits`;
|
|
191
|
+
const timeout = withTimeout(input.timeoutMs);
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetchWithRetry(fetchFn, endpoint, {
|
|
194
|
+
method: 'GET',
|
|
195
|
+
headers: {
|
|
196
|
+
...buildCliHeaders(),
|
|
197
|
+
...(input.apiKey ? { 'X-API-Key': input.apiKey } : {}),
|
|
198
|
+
},
|
|
199
|
+
signal: timeout.signal,
|
|
200
|
+
}, 1);
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
throw new Error(mapLimitsStatusMessage(response.status));
|
|
203
|
+
}
|
|
204
|
+
const parsed = await parseJsonSafe(response);
|
|
205
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
206
|
+
throw new Error('Unexpected API response.');
|
|
207
|
+
}
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
const typed = error;
|
|
212
|
+
if (typed.name === 'AbortError') {
|
|
213
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
214
|
+
}
|
|
215
|
+
if (typed.message === 'Unexpected API response.') {
|
|
216
|
+
throw typed;
|
|
217
|
+
}
|
|
218
|
+
if (typed.message.includes('Invalid API key.') ||
|
|
219
|
+
typed.message.includes('Access denied') ||
|
|
220
|
+
typed.message.includes('Rate limit exceeded.') ||
|
|
221
|
+
typed.message.includes('SkillGuard API error.') ||
|
|
222
|
+
typed.message.includes('SkillGuard limits request failed.')) {
|
|
223
|
+
throw typed;
|
|
224
|
+
}
|
|
225
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
timeout.cancel();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
104
231
|
export async function fetchJwks(input) {
|
|
105
232
|
const fetchFn = input.fetchImpl || fetch;
|
|
106
233
|
const endpoint = `${input.baseUrl}/.well-known/skillguard-jwks.json`;
|
package/dist/lib/help.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
const COMMAND_ALIASES = {
|
|
2
|
+
man: 'help',
|
|
3
|
+
};
|
|
4
|
+
function normalizeTopic(topic) {
|
|
5
|
+
if (!topic) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const normalized = topic.trim().toLowerCase();
|
|
9
|
+
const aliased = COMMAND_ALIASES[normalized] || normalized;
|
|
10
|
+
if (aliased === 'help' ||
|
|
11
|
+
aliased === 'init' ||
|
|
12
|
+
aliased === 'scan' ||
|
|
13
|
+
aliased === 'gate' ||
|
|
14
|
+
aliased === 'limits' ||
|
|
15
|
+
aliased === 'verify' ||
|
|
16
|
+
aliased === 'workflow') {
|
|
17
|
+
return aliased;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
export function renderGeneralHelp(version) {
|
|
22
|
+
return `SkillGuard CLI ${version}
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
skillguard <command> [options]
|
|
26
|
+
|
|
27
|
+
Core commands:
|
|
28
|
+
init Save API key in local CLI config
|
|
29
|
+
scan Scan one file or recursively scan a directory
|
|
30
|
+
gate CI gate mode (0 pass, 1 fail)
|
|
31
|
+
limits Show anonymous/owner/agent remaining limits
|
|
32
|
+
verify Verify signed SKILL.md via SkillGuard JWKS
|
|
33
|
+
workflow Generate GitHub Actions workflow file
|
|
34
|
+
|
|
35
|
+
Help commands:
|
|
36
|
+
help [command]
|
|
37
|
+
man [command]
|
|
38
|
+
|
|
39
|
+
Quick start:
|
|
40
|
+
skillguard scan SKILL.md
|
|
41
|
+
skillguard gate .
|
|
42
|
+
skillguard workflow
|
|
43
|
+
skillguard workflow --auto-gh-push
|
|
44
|
+
|
|
45
|
+
Global flags:
|
|
46
|
+
-h, --help Show help
|
|
47
|
+
-v, --version Show version
|
|
48
|
+
--workflow Alias for "workflow"
|
|
49
|
+
|
|
50
|
+
Run "skillguard help <command>" for full command details.`;
|
|
51
|
+
}
|
|
52
|
+
function renderHelpCommandHelp() {
|
|
53
|
+
return `NAME
|
|
54
|
+
skillguard help, skillguard man - show detailed command help
|
|
55
|
+
|
|
56
|
+
SYNOPSIS
|
|
57
|
+
skillguard help [command]
|
|
58
|
+
skillguard man [command]
|
|
59
|
+
|
|
60
|
+
DESCRIPTION
|
|
61
|
+
Shows command-level help in a man-page style format.
|
|
62
|
+
If no command is provided, prints general CLI help.
|
|
63
|
+
|
|
64
|
+
EXAMPLES
|
|
65
|
+
skillguard help scan
|
|
66
|
+
skillguard man workflow`;
|
|
67
|
+
}
|
|
68
|
+
function renderInitHelp() {
|
|
69
|
+
return `NAME
|
|
70
|
+
skillguard init - save API key in local config
|
|
71
|
+
|
|
72
|
+
SYNOPSIS
|
|
73
|
+
skillguard init [--api-key <key>] [--base-url <url>]
|
|
74
|
+
|
|
75
|
+
DESCRIPTION
|
|
76
|
+
Writes ~/.config/skillguard/config.json with apiKey and apiUrl.
|
|
77
|
+
If --api-key is omitted, prompts interactively.
|
|
78
|
+
|
|
79
|
+
OPTIONS
|
|
80
|
+
--api-key <key> API key to persist
|
|
81
|
+
--base-url <url> API base URL (default: https://skillguard.ai)
|
|
82
|
+
|
|
83
|
+
EXIT CODES
|
|
84
|
+
0 success
|
|
85
|
+
4 usage/input/config error
|
|
86
|
+
5 external runtime error`;
|
|
87
|
+
}
|
|
88
|
+
function renderScanHelp() {
|
|
89
|
+
return `NAME
|
|
90
|
+
skillguard scan - scan SKILL.md content for risk
|
|
91
|
+
|
|
92
|
+
SYNOPSIS
|
|
93
|
+
skillguard scan [path] [options]
|
|
94
|
+
|
|
95
|
+
DESCRIPTION
|
|
96
|
+
Scans one file or recursively scans a directory for SKILL.md files.
|
|
97
|
+
If no path is provided, scans from current directory.
|
|
98
|
+
If no API key is configured, uses anonymous mode (rate-limited).
|
|
99
|
+
|
|
100
|
+
OPTIONS
|
|
101
|
+
--json Machine-readable JSON output
|
|
102
|
+
--fail-on <safe|warning|dangerous>
|
|
103
|
+
Explicit threshold mode (exit 0/1)
|
|
104
|
+
--timeout <ms> Request timeout (default: 30000)
|
|
105
|
+
--base-url <url> API base URL
|
|
106
|
+
--api-key <key> Override API key
|
|
107
|
+
--dry-run Show target files, skip API call
|
|
108
|
+
--quiet Suppress output, keep exit code
|
|
109
|
+
--no-color Disable ANSI colors
|
|
110
|
+
--skip-node-modules Skip node_modules (default: enabled)
|
|
111
|
+
--scan-all Disable skip filters for recursive scan
|
|
112
|
+
|
|
113
|
+
EXIT CODES
|
|
114
|
+
default mode (no --fail-on): safe=0, warning=1, dangerous=2
|
|
115
|
+
threshold mode (--fail-on): 0 below threshold, 1 threshold reached
|
|
116
|
+
4 usage/input/config error
|
|
117
|
+
5 network/API/runtime failure`;
|
|
118
|
+
}
|
|
119
|
+
function renderGateHelp() {
|
|
120
|
+
return `NAME
|
|
121
|
+
skillguard gate - CI gate mode for deterministic pass/fail
|
|
122
|
+
|
|
123
|
+
SYNOPSIS
|
|
124
|
+
skillguard gate [path] [options]
|
|
125
|
+
|
|
126
|
+
DESCRIPTION
|
|
127
|
+
Wrapper around scan with explicit threshold mode enabled.
|
|
128
|
+
Default fail-on is warning.
|
|
129
|
+
|
|
130
|
+
OPTIONS
|
|
131
|
+
Same as "skillguard scan".
|
|
132
|
+
--fail-on defaults to warning in gate mode.
|
|
133
|
+
|
|
134
|
+
EXIT CODES
|
|
135
|
+
0 below threshold
|
|
136
|
+
1 threshold reached
|
|
137
|
+
4 usage/input/config error
|
|
138
|
+
5 network/API/runtime failure`;
|
|
139
|
+
}
|
|
140
|
+
function renderLimitsHelp() {
|
|
141
|
+
return `NAME
|
|
142
|
+
skillguard limits - show remaining limits/quota
|
|
143
|
+
|
|
144
|
+
SYNOPSIS
|
|
145
|
+
skillguard limits [--json] [--timeout <ms>] [--base-url <url>] [--api-key <key>] [--no-color]
|
|
146
|
+
|
|
147
|
+
DESCRIPTION
|
|
148
|
+
Fetches /api/v1/limits and prints current request mode:
|
|
149
|
+
anonymous, owner, or agent.
|
|
150
|
+
|
|
151
|
+
OPTIONS
|
|
152
|
+
--json JSON output
|
|
153
|
+
--timeout <ms> Request timeout (default: 30000)
|
|
154
|
+
--base-url <url> API base URL
|
|
155
|
+
--api-key <key> Optional API key
|
|
156
|
+
--no-color Disable ANSI colors
|
|
157
|
+
|
|
158
|
+
EXIT CODES
|
|
159
|
+
0 success
|
|
160
|
+
4 usage/input/config error
|
|
161
|
+
5 network/API/runtime failure`;
|
|
162
|
+
}
|
|
163
|
+
function renderVerifyHelp() {
|
|
164
|
+
return `NAME
|
|
165
|
+
skillguard verify - verify signed SKILL.md content
|
|
166
|
+
|
|
167
|
+
SYNOPSIS
|
|
168
|
+
skillguard verify <path> [--json] [--timeout <ms>] [--base-url <url>]
|
|
169
|
+
|
|
170
|
+
DESCRIPTION
|
|
171
|
+
Verifies SkillGuard Ed25519 signature and content hash using JWKS.
|
|
172
|
+
|
|
173
|
+
OPTIONS
|
|
174
|
+
--json JSON output
|
|
175
|
+
--timeout <ms> Request timeout (default: 30000)
|
|
176
|
+
--base-url <url> API base URL
|
|
177
|
+
|
|
178
|
+
EXIT CODES
|
|
179
|
+
0 signature valid
|
|
180
|
+
1 invalid/tampered/expired signature
|
|
181
|
+
4 usage/input/config error
|
|
182
|
+
5 network/API/runtime failure`;
|
|
183
|
+
}
|
|
184
|
+
function renderWorkflowHelp() {
|
|
185
|
+
return `NAME
|
|
186
|
+
skillguard workflow - generate GitHub Actions workflow for CI gate
|
|
187
|
+
|
|
188
|
+
SYNOPSIS
|
|
189
|
+
skillguard workflow [options]
|
|
190
|
+
skillguard --workflow [options]
|
|
191
|
+
|
|
192
|
+
DESCRIPTION
|
|
193
|
+
Writes .github/workflows/skillguard.yml configured to run:
|
|
194
|
+
npx @skillguard/cli gate . --fail-on warning
|
|
195
|
+
|
|
196
|
+
OPTIONS
|
|
197
|
+
--path <file> Output workflow path
|
|
198
|
+
(default: .github/workflows/skillguard.yml)
|
|
199
|
+
--scan-path <path> Path passed to gate command (default: .)
|
|
200
|
+
--fail-on <safe|warning|dangerous>
|
|
201
|
+
Gate threshold (default: warning)
|
|
202
|
+
--print Print YAML to stdout, do not write files
|
|
203
|
+
--force Overwrite existing file
|
|
204
|
+
--auto-gh-push Run git add/commit/push after writing
|
|
205
|
+
|
|
206
|
+
EXIT CODES
|
|
207
|
+
0 success
|
|
208
|
+
4 usage/input/config error
|
|
209
|
+
5 runtime/git failure
|
|
210
|
+
|
|
211
|
+
EXAMPLES
|
|
212
|
+
skillguard workflow
|
|
213
|
+
skillguard workflow --print
|
|
214
|
+
skillguard workflow --auto-gh-push
|
|
215
|
+
skillguard --workflow --force`;
|
|
216
|
+
}
|
|
217
|
+
export function renderTopicHelp(command) {
|
|
218
|
+
const topic = normalizeTopic(command);
|
|
219
|
+
if (!topic) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
switch (topic) {
|
|
223
|
+
case 'help':
|
|
224
|
+
return renderHelpCommandHelp();
|
|
225
|
+
case 'init':
|
|
226
|
+
return renderInitHelp();
|
|
227
|
+
case 'scan':
|
|
228
|
+
return renderScanHelp();
|
|
229
|
+
case 'gate':
|
|
230
|
+
return renderGateHelp();
|
|
231
|
+
case 'limits':
|
|
232
|
+
return renderLimitsHelp();
|
|
233
|
+
case 'verify':
|
|
234
|
+
return renderVerifyHelp();
|
|
235
|
+
case 'workflow':
|
|
236
|
+
return renderWorkflowHelp();
|
|
237
|
+
default:
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const CLI_VERSION = "0.4.0";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLI_VERSION = '0.4.0';
|