@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 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: Skill Security Scan
103
- 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'
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 scan ./skills
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
- const VERSION = '0.2.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] [--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
- 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
+ }
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
- 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);
79
98
  if (options.version) {
80
99
  console.log(VERSION);
81
100
  return 0;
82
101
  }
83
102
  if (options.help) {
84
- printUsage();
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,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,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
- if (!resolvedApiKey) {
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 = await scanContent({
178
- baseUrl,
179
- apiKey: resolvedApiKey.apiKey,
180
- content,
181
- timeoutMs: options.timeoutMs,
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: '0.2.0',
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`;
@@ -0,0 +1,2 @@
1
+ export declare function renderGeneralHelp(version: string): string;
2
+ export declare function renderTopicHelp(command: string | undefined): string | null;
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillguard/cli",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Security scanner for AI agent skill files",
5
5
  "type": "module",
6
6
  "bin": {