@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
49 lines (48 loc) • 2.91 kB
JavaScript
import { z } from "zod";
// The grammar gitboy's routes accept, shared by the scoped SSH gateway (which
// refuses anything else before the Hook sees it) and the Hook routes (which
// apply it again for every other caller). Kept free of node and store imports
// so the gateway stays cheap to start.
export const PROJECT_NAME = /^[a-z0-9][a-z0-9_-]{0,99}$/;
export const FINDING_TYPE_VALUES = ["decision", "pitfall", "pattern", "bug"];
export const MAX_FILE_PATHS = 500;
export const MAX_SEARCH_QUERY = 1000;
export const MAX_SEARCH_LIMIT = 20;
export const MAX_FINDING_BODY = 8192;
export const MAX_FINDING_TEXT = 5000;
/** A repository-relative path: `/`-separated segments of ordinary file-name
* characters, no empty, `.` or `..` segment, no leading `/`, at most 1024. */
export function isRepoPath(value) {
if (value.length < 1 || value.length > 1024)
return false;
if (!/^[A-Za-z0-9 ._@+~,=()[\]{}#!$&'-]+(?:\/[A-Za-z0-9 ._@+~,=()[\]{}#!$&'-]+)*$/.test(value))
return false;
return value.split("/").every(segment => segment !== "." && segment !== ".." && segment.trim() === segment);
}
/** A branch as git allows it, narrowed: no `..`, `@{`, leading `-` or `/`,
* trailing `/`, `.lock` or `.`, at most 200 characters. */
export function isBranchName(value) {
if (!/^[A-Za-z0-9._\/+-]{1,200}$/.test(value))
return false;
if (value.startsWith("-") || value.startsWith("/") || value.endsWith("/") || value.endsWith(".") || value.endsWith(".lock"))
return false;
return !value.includes("..") && !value.includes("//") && !value.split("/").some(part => part.startsWith("."));
}
/** Search text: an error excerpt may carry tabs and newlines, nothing else below space. */
export function isSearchText(value) {
return value.trim().length > 0 && value.length <= MAX_SEARCH_QUERY && !/[\x00-\x08\x0b-\x1f\x7f]/.test(value);
}
const noControls = (value) => !/[\x00-\x08\x0b-\x1f\x7f]/.test(value);
export const findingWriteSchema = z.object({
text: z.string().max(MAX_FINDING_TEXT).refine(noControls, "Text may contain tabs and newlines but no other control characters.")
.transform(value => value.replace(/\s+/g, " ").trim()).refine(value => value.length > 0, "Text is empty."),
type: z.enum(FINDING_TYPE_VALUES).optional(),
citation: z.object({
file: z.string().refine(isRepoPath, "citation.file must be a repository-relative path.").optional(),
line: z.number().int().min(1).max(10_000_000).optional(),
commit: z.string().regex(/^[0-9a-fA-F]{7,64}$/).optional(),
name: z.string().min(1).max(200).refine(value => !/[\x00-\x1f\x7f]/.test(value)).optional(),
}).strict().optional(),
}).strict();
/** Complete one task by its stable id (`bid:` plus 8 hex digits), as `/tasks?branch=` lists it. */
export const taskCompleteSchema = z.object({ id: z.string().regex(/^bid:[0-9a-f]{8}$/) }).strict();