Reorganize: Move all skills to skills/ folder
- Created skills/ directory - Moved 272 skills to skills/ subfolder - Kept agents/ at root level - Kept installation scripts and docs at root level Repository structure: - skills/ - All 272 skills from skills.sh - agents/ - Agent definitions - *.sh, *.ps1 - Installation scripts - README.md, etc. - Documentation Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
222
skills/plugins/claude-code-safety-net/src/core/config.ts
Normal file
222
skills/plugins/claude-code-safety-net/src/core/config.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
COMMAND_PATTERN,
|
||||
type Config,
|
||||
MAX_REASON_LENGTH,
|
||||
NAME_PATTERN,
|
||||
type ValidationResult,
|
||||
} from '../types.ts';
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
version: 1,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
export interface LoadConfigOptions {
|
||||
/** Override user config directory (for testing) */
|
||||
userConfigDir?: string;
|
||||
}
|
||||
|
||||
export function loadConfig(cwd?: string, options?: LoadConfigOptions): Config {
|
||||
const safeCwd = typeof cwd === 'string' ? cwd : process.cwd();
|
||||
const userConfigDir = options?.userConfigDir ?? join(homedir(), '.cc-safety-net');
|
||||
const userConfigPath = join(userConfigDir, 'config.json');
|
||||
const projectConfigPath = join(safeCwd, '.safety-net.json');
|
||||
|
||||
const userConfig = loadSingleConfig(userConfigPath);
|
||||
const projectConfig = loadSingleConfig(projectConfigPath);
|
||||
|
||||
return mergeConfigs(userConfig, projectConfig);
|
||||
}
|
||||
|
||||
function loadSingleConfig(path: string): Config | null {
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
if (!content.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
const result = validateConfig(parsed);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure rules array exists (may be undefined if not in input)
|
||||
const cfg = parsed as Record<string, unknown>;
|
||||
return {
|
||||
version: cfg.version as number,
|
||||
rules: (cfg.rules as Config['rules']) ?? [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeConfigs(userConfig: Config | null, projectConfig: Config | null): Config {
|
||||
if (!userConfig && !projectConfig) {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
if (!userConfig) {
|
||||
return projectConfig ?? DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
if (!projectConfig) {
|
||||
return userConfig;
|
||||
}
|
||||
|
||||
const projectRuleNames = new Set(projectConfig.rules.map((r) => r.name.toLowerCase()));
|
||||
|
||||
const mergedRules = [
|
||||
...userConfig.rules.filter((r) => !projectRuleNames.has(r.name.toLowerCase())),
|
||||
...projectConfig.rules,
|
||||
];
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
rules: mergedRules,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function validateConfig(config: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const ruleNames = new Set<string>();
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
errors.push('Config must be an object');
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
|
||||
const cfg = config as Record<string, unknown>;
|
||||
|
||||
if (cfg.version !== 1) {
|
||||
errors.push('version must be 1');
|
||||
}
|
||||
|
||||
if (cfg.rules !== undefined) {
|
||||
if (!Array.isArray(cfg.rules)) {
|
||||
errors.push('rules must be an array');
|
||||
} else {
|
||||
for (let i = 0; i < cfg.rules.length; i++) {
|
||||
const rule = cfg.rules[i] as unknown;
|
||||
const ruleErrors = validateRule(rule, i, ruleNames);
|
||||
errors.push(...ruleErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
|
||||
function validateRule(rule: unknown, index: number, ruleNames: Set<string>): string[] {
|
||||
const errors: string[] = [];
|
||||
const prefix = `rules[${index}]`;
|
||||
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
errors.push(`${prefix}: must be an object`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
const r = rule as Record<string, unknown>;
|
||||
|
||||
if (typeof r.name !== 'string') {
|
||||
errors.push(`${prefix}.name: required string`);
|
||||
} else {
|
||||
if (!NAME_PATTERN.test(r.name)) {
|
||||
errors.push(
|
||||
`${prefix}.name: must match pattern (letters, numbers, hyphens, underscores; max 64 chars)`,
|
||||
);
|
||||
}
|
||||
const lowerName = r.name.toLowerCase();
|
||||
if (ruleNames.has(lowerName)) {
|
||||
errors.push(`${prefix}.name: duplicate rule name "${r.name}"`);
|
||||
} else {
|
||||
ruleNames.add(lowerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof r.command !== 'string') {
|
||||
errors.push(`${prefix}.command: required string`);
|
||||
} else if (!COMMAND_PATTERN.test(r.command)) {
|
||||
errors.push(`${prefix}.command: must match pattern (letters, numbers, hyphens, underscores)`);
|
||||
}
|
||||
|
||||
if (r.subcommand !== undefined) {
|
||||
if (typeof r.subcommand !== 'string') {
|
||||
errors.push(`${prefix}.subcommand: must be a string if provided`);
|
||||
} else if (!COMMAND_PATTERN.test(r.subcommand)) {
|
||||
errors.push(
|
||||
`${prefix}.subcommand: must match pattern (letters, numbers, hyphens, underscores)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(r.block_args)) {
|
||||
errors.push(`${prefix}.block_args: required array`);
|
||||
} else {
|
||||
if (r.block_args.length === 0) {
|
||||
errors.push(`${prefix}.block_args: must have at least one element`);
|
||||
}
|
||||
for (let i = 0; i < r.block_args.length; i++) {
|
||||
const arg = r.block_args[i];
|
||||
if (typeof arg !== 'string') {
|
||||
errors.push(`${prefix}.block_args[${i}]: must be a string`);
|
||||
} else if (arg === '') {
|
||||
errors.push(`${prefix}.block_args[${i}]: must not be empty`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof r.reason !== 'string') {
|
||||
errors.push(`${prefix}.reason: required string`);
|
||||
} else if (r.reason === '') {
|
||||
errors.push(`${prefix}.reason: must not be empty`);
|
||||
} else if (r.reason.length > MAX_REASON_LENGTH) {
|
||||
errors.push(`${prefix}.reason: must be at most ${MAX_REASON_LENGTH} characters`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateConfigFile(path: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const ruleNames = new Set<string>();
|
||||
|
||||
if (!existsSync(path)) {
|
||||
errors.push(`File not found: ${path}`);
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
if (!content.trim()) {
|
||||
errors.push('Config file is empty');
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return validateConfig(parsed);
|
||||
} catch (e) {
|
||||
errors.push(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return { errors, ruleNames };
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserConfigPath(): string {
|
||||
return join(homedir(), '.cc-safety-net', 'config.json');
|
||||
}
|
||||
|
||||
export function getProjectConfigPath(cwd?: string): string {
|
||||
return resolve(cwd ?? process.cwd(), '.safety-net.json');
|
||||
}
|
||||
|
||||
export type { ValidationResult };
|
||||
Reference in New Issue
Block a user