@skillguard/cli 0.1.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 CHANGED
@@ -1,5 +1,30 @@
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
+
19
+ ## 0.2.0
20
+
21
+ - Added `skillguard scan` without path argument (defaults to recursive scan from current directory)
22
+ - Added recursive skip controls: `--skip-node-modules` and `--scan-all`
23
+ - Added worst-score default exit mode for scan: `safe=0`, `warning=1`, `dangerous=2`
24
+ - Preserved legacy threshold mode when `--fail-on` is explicitly provided
25
+ - Added JSON scan output fields: `worst_score`, `exit_code`, `mode`
26
+ - Updated non-scan failure exits across commands: usage/input/config -> `4`, network/API/runtime -> `5`
27
+
3
28
  ## 0.1.0
4
29
 
5
30
  - Initial release of `@skillguard/cli`
package/README.md CHANGED
@@ -8,6 +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
+
13
+ ### Scan all skills in current repo
14
+
15
+ ```bash
16
+ npx @skillguard/cli scan
17
+ ```
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
+
11
31
  ## Setup
12
32
 
13
33
  ```bash
@@ -40,6 +60,31 @@ npx @skillguard/cli scan ./skills --fail-on warning
40
60
  npx @skillguard/cli scan ./skills --json > skillguard-report.json
41
61
  ```
42
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
+
81
+ ### Worst-score CI exit code (recommended)
82
+
83
+ ```bash
84
+ npx @skillguard/cli scan
85
+ # exit: safe=0, warning=1, dangerous=2
86
+ ```
87
+
43
88
  ### Verify signature
44
89
 
45
90
  ```bash
@@ -51,13 +96,42 @@ npx @skillguard/cli verify ./SKILL.md
51
96
  ### scan
52
97
 
53
98
  - `--json`
54
- - `--fail-on <safe|warning|dangerous>` (default: `warning`)
99
+ - `--fail-on <safe|warning|dangerous>` (optional, enables legacy threshold mode)
55
100
  - `--timeout <ms>` (default: `30000`)
56
101
  - `--base-url <url>` (default: `https://skillguard.ai`)
57
102
  - `--api-key <key>`
58
103
  - `--dry-run`
59
104
  - `--quiet`
60
105
  - `--no-color`
106
+ - `--skip-node-modules` (default: enabled)
107
+ - `--scan-all` (disable skip filters, scans everything recursively)
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]`
61
135
 
62
136
  ### verify
63
137
 
@@ -67,16 +141,40 @@ npx @skillguard/cli verify ./SKILL.md
67
141
 
68
142
  ## Exit codes
69
143
 
70
- - `0` success / below threshold
71
- - `1` threshold exceeded (scan) or invalid/tampered signature (verify)
72
- - `2` usage/input error
73
- - `3` network/API/runtime external failure
144
+ - `scan` default mode (no `--fail-on`):
145
+ - `0` worst score is `safe`
146
+ - `1` worst score is `warning`
147
+ - `2` worst score is `dangerous`
148
+ - `scan` threshold mode (`--fail-on` provided):
149
+ - `0` below threshold
150
+ - `1` threshold exceeded
151
+ - `verify`:
152
+ - `0` signature valid
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
161
+ - all commands:
162
+ - `4` usage/input/config error
163
+ - `5` network/API/runtime external failure
74
164
 
75
165
  ## GitHub Actions example
76
166
 
77
167
  ```yaml
78
- name: Skill Security Scan
79
- on: [push, pull_request]
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'
80
178
 
81
179
  jobs:
82
180
  scan:
@@ -86,9 +184,7 @@ jobs:
86
184
  - uses: actions/setup-node@v4
87
185
  with:
88
186
  node-version: '20'
89
- - run: npx @skillguard/cli scan ./skills
90
- env:
91
- SKILLGUARD_API_KEY: ${{ secrets.SKILLGUARD_API_KEY }}
187
+ - run: npx @skillguard/cli gate . --fail-on warning
92
188
  ```
93
189
 
94
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
- const VERSION = '0.1.0';
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(`SkillGuard CLI ${VERSION}
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]
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) {
@@ -48,9 +43,16 @@ function parseShared(args) {
48
43
  quiet: { type: 'boolean' },
49
44
  'no-color': { type: 'boolean' },
50
45
  'fail-on': { type: 'string' },
46
+ 'scan-all': { type: 'boolean' },
47
+ 'skip-node-modules': { type: 'boolean' },
51
48
  timeout: { type: 'string' },
52
49
  'base-url': { type: 'string' },
53
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' },
54
56
  },
55
57
  strict: false,
56
58
  });
@@ -59,9 +61,17 @@ function parseShared(args) {
59
61
  positionals: parsed.positionals,
60
62
  };
61
63
  }
64
+ function isFailOnExplicit(args) {
65
+ return args.some((arg) => arg === '--fail-on' || arg.startsWith('--fail-on='));
66
+ }
62
67
  async function run() {
63
68
  const argv = process.argv.slice(2);
64
- const command = argv[0];
69
+ let command = argv[0];
70
+ let commandArgs = argv.slice(1);
71
+ if (command === '--workflow') {
72
+ command = 'workflow';
73
+ commandArgs = argv.slice(1);
74
+ }
65
75
  if (!command || command === '--help' || command === '-h') {
66
76
  printUsage();
67
77
  return 0;
@@ -70,13 +80,33 @@ async function run() {
70
80
  console.log(VERSION);
71
81
  return 0;
72
82
  }
73
- const { options, positionals } = parseShared(argv.slice(1));
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);
74
98
  if (options.version) {
75
99
  console.log(VERSION);
76
100
  return 0;
77
101
  }
78
102
  if (options.help) {
79
- printUsage();
103
+ const topicHelp = renderTopicHelp(command);
104
+ if (topicHelp) {
105
+ console.log(topicHelp);
106
+ }
107
+ else {
108
+ printUsage();
109
+ }
80
110
  return 0;
81
111
  }
82
112
  if (command === 'init') {
@@ -87,23 +117,24 @@ async function run() {
87
117
  }
88
118
  if (command === 'scan') {
89
119
  const pathArg = positionals[0];
90
- if (!pathArg) {
91
- console.error('Missing required <path> argument.');
92
- return 2;
93
- }
94
120
  let parsedTimeout;
95
121
  let failOn;
96
122
  try {
97
123
  parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
98
- failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
124
+ if (isFailOnExplicit(argv.slice(1))) {
125
+ failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
126
+ }
99
127
  }
100
128
  catch (error) {
101
129
  console.error(error.message);
102
- return 2;
130
+ return 4;
103
131
  }
104
132
  const scanOptions = {
105
133
  json: options.json === true,
106
134
  failOn,
135
+ failOnExplicit: isFailOnExplicit(argv.slice(1)),
136
+ scanAll: options['scan-all'] === true,
137
+ skipNodeModules: options['scan-all'] === true ? false : options['skip-node-modules'] !== false,
107
138
  timeoutMs: parsedTimeout,
108
139
  baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
109
140
  apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
@@ -113,11 +144,74 @@ async function run() {
113
144
  };
114
145
  return await runScan(pathArg, scanOptions);
115
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
+ }
116
210
  if (command === 'verify') {
117
211
  const pathArg = positionals[0];
118
212
  if (!pathArg) {
119
213
  console.error('Missing required <path> argument.');
120
- return 2;
214
+ return 4;
121
215
  }
122
216
  let parsedTimeout;
123
217
  try {
@@ -125,7 +219,7 @@ async function run() {
125
219
  }
126
220
  catch (error) {
127
221
  console.error(error.message);
128
- return 2;
222
+ return 4;
129
223
  }
130
224
  const verifyOptions = {
131
225
  baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
@@ -136,7 +230,7 @@ async function run() {
136
230
  }
137
231
  console.error(`Unknown command: ${command}`);
138
232
  printUsage();
139
- return 2;
233
+ return 4;
140
234
  }
141
235
  run()
142
236
  .then((code) => {
@@ -144,5 +238,5 @@ run()
144
238
  })
145
239
  .catch(() => {
146
240
  console.error('Unexpected CLI error.');
147
- process.exitCode = 3;
241
+ process.exitCode = 5;
148
242
  });
@@ -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,8 @@
1
+ export interface LimitsOptions {
2
+ json: boolean;
3
+ timeoutMs: number;
4
+ baseUrl?: string;
5
+ apiKey?: string;
6
+ noColor: boolean;
7
+ }
8
+ export declare function runLimits(options: LimitsOptions): Promise<number>;
@@ -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
+ }
@@ -1,7 +1,10 @@
1
1
  import { type ScanFinding, type ScanScore } from '../lib/api.js';
2
2
  export interface ScanOptions {
3
3
  json: boolean;
4
- failOn: ScanScore;
4
+ failOn?: ScanScore;
5
+ failOnExplicit: boolean;
6
+ scanAll: boolean;
7
+ skipNodeModules: boolean;
5
8
  timeoutMs: number;
6
9
  baseUrl?: string;
7
10
  apiKey?: string;
@@ -15,4 +18,4 @@ export interface ScanResult {
15
18
  findings: ScanFinding[];
16
19
  signatureStatus: string;
17
20
  }
18
- export declare function runScan(inputPath: string, options: ScanOptions): Promise<number>;
21
+ export declare function runScan(inputPath: string | undefined, options: ScanOptions): Promise<number>;