@functional-systems/srcx 0.1.0-dev.23 → 0.1.0-dev.25

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/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  normalizeAnalyzers,
18
18
  ANALYZERS,
19
19
  } from "./build.js";
20
+ import { normalizeFilePolicy } from "./file-policy.js";
20
21
  import { invariant, optionalJson } from "./common.js";
21
22
  import { LambdaRemote } from "./remote.js";
22
23
  import {
@@ -36,6 +37,7 @@ import {
36
37
  import { resolveVersion, syncTags } from "./releases.js";
37
38
  import { directHandle, loadHandle, readHandle, search } from "./search.js";
38
39
  import { candidateLimit } from "./rerank.js";
40
+ import { manageSkill, SKILL_AGENTS, SKILL_SCOPES } from "./skills.js";
39
41
  const cli = new Command()
40
42
  .name("srcx")
41
43
  .description("Version-aware code search on LambdaDB")
@@ -63,10 +65,41 @@ const analyzerOption = (description: string) =>
63
65
  ).argParser((value) =>
64
66
  normalizeAnalyzers(value.split(",").map((name) => name.trim())),
65
67
  );
68
+ const filePolicyOption = () =>
69
+ new Option(
70
+ "--file-policy <json>",
71
+ "Path to file policy JSON; pinned at registration or local dry run",
72
+ ).argParser((path) =>
73
+ normalizeFilePolicy(JSON.parse(readFileSync(path, "utf8"))),
74
+ );
66
75
  async function connected() {
67
76
  const settings = await loadSettings();
68
77
  return { settings, remote: new LambdaRemote(settings) };
69
78
  }
79
+ const skills = cli
80
+ .command("skills")
81
+ .description(
82
+ "Install and manage the bundled agent skill (no credentials required)",
83
+ );
84
+ for (const operation of ["install", "update", "remove", "status"] as const) {
85
+ skills
86
+ .command(operation)
87
+ .addOption(
88
+ new Option("--agent <name>", "Agent to configure")
89
+ .choices(SKILL_AGENTS)
90
+ .makeOptionMandatory(),
91
+ )
92
+ .addOption(
93
+ new Option("--scope <scope>", "Installation scope")
94
+ .choices(SKILL_SCOPES)
95
+ .default("user"),
96
+ )
97
+ .option(
98
+ "--path <directory>",
99
+ "Project directory (project scope only; default: current directory)",
100
+ )
101
+ .action(async (options) => output(await manageSkill(operation, options)));
102
+ }
70
103
  cli
71
104
  .command("configure")
72
105
  .requiredOption("--endpoint <origin>")
@@ -106,6 +139,7 @@ repo
106
139
  .option("--description <text>")
107
140
  .option("--tag <key=value>", "One optional Collection metadata tag")
108
141
  .addOption(analyzerOption("Comma-separated text analyzers"))
142
+ .addOption(filePolicyOption())
109
143
  .addOption(
110
144
  new Option(
111
145
  "--embedding <model>",
@@ -128,7 +162,7 @@ repo
128
162
  remote: o.remote,
129
163
  description: o.description,
130
164
  labels,
131
- preset: presetFor(o.embedding, o.analyzers),
165
+ preset: presetFor(o.embedding, o.analyzers, o.filePolicy),
132
166
  }),
133
167
  );
134
168
  });
@@ -156,6 +190,7 @@ cli
156
190
  .option("--repo <name>", "Registered remote repository")
157
191
  .option("--ref <ref>", "Local branch, Git tag, or commit OID")
158
192
  .option("--dry-run", "Only materialize and validate a local build")
193
+ .addOption(filePolicyOption())
159
194
  .addOption(
160
195
  new Option(
161
196
  "--embedding <model>",
@@ -187,6 +222,10 @@ cli
187
222
  !o.analyzers || (o.dryRun && o.path),
188
223
  "--analyzers is only accepted with --dry-run --path; connected imports use the repository preset.",
189
224
  );
225
+ invariant(
226
+ !o.filePolicy || (o.dryRun && o.path),
227
+ "--file-policy is only accepted with --dry-run --path; connected imports use the repository preset.",
228
+ );
190
229
  invariant(!(o.path && o.repo), "Choose --path or --repo.");
191
230
  invariant(
192
231
  !o.previous || o.dryRun,
@@ -236,7 +275,7 @@ cli
236
275
  await validateBuild(b);
237
276
  } else {
238
277
  let source;
239
- let preset = presetFor(o.embedding, o.analyzers);
278
+ let preset = presetFor(o.embedding, o.analyzers, o.filePolicy);
240
279
  if (o.path) source = await identity(o.path, o.remote);
241
280
  else {
242
281
  invariant(o.repo, "Use --path for a credential-free dry run.");
@@ -264,6 +303,8 @@ cli
264
303
  path: e.path,
265
304
  status: e.status,
266
305
  reason: e.reason,
306
+ retrieval: e.retrieval,
307
+ policyReason: e.policyReason,
267
308
  parseStatus: e.parseStatus,
268
309
  })),
269
310
  uploaded: false,
@@ -0,0 +1,157 @@
1
+ import { invariant } from "./common.js";
2
+
3
+ export type FileAction = "exclude" | "lexical" | "semantic";
4
+ export type FileRule = { pattern: string; action: FileAction };
5
+ export type FilePolicy = { version: 1; rules: FileRule[] };
6
+ export const FILE_POLICY: FilePolicy = { version: 1, rules: [] };
7
+ export type FileDecision = { action: FileAction; reason: string };
8
+
9
+ /** Deliberately small glob syntax: repo-relative paths, *, ?, and whole-segment **. */
10
+ function glob(pattern: string): RegExp {
11
+ let expression = "^";
12
+ const parts = pattern.split("/");
13
+ for (const [index, part] of parts.entries()) {
14
+ if (part === "**")
15
+ expression += index === parts.length - 1 ? "[\\s\\S]*" : "(?:[^/]+/)*";
16
+ else {
17
+ for (const char of part)
18
+ expression +=
19
+ char === "*"
20
+ ? "[^/]*"
21
+ : char === "?"
22
+ ? "[^/]"
23
+ : char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
+ if (index !== parts.length - 1) expression += "/";
25
+ }
26
+ }
27
+ return new RegExp(expression + "$", "u");
28
+ }
29
+
30
+ export function normalizeFilePolicy(value: unknown): FilePolicy {
31
+ invariant(
32
+ value !== null && typeof value === "object" && !Array.isArray(value),
33
+ "File policy must be an object.",
34
+ );
35
+ const policy = value as FilePolicy;
36
+ invariant(
37
+ Object.keys(policy).sort().join(",") === "rules,version" &&
38
+ policy.version === 1 &&
39
+ Array.isArray(policy.rules) &&
40
+ policy.rules.length <= 100,
41
+ "File policy requires version: 1 and at most 100 rules.",
42
+ );
43
+ const rules = policy.rules.map((rule) => {
44
+ invariant(
45
+ rule &&
46
+ typeof rule === "object" &&
47
+ Object.keys(rule).sort().join(",") === "action,pattern" &&
48
+ ["exclude", "lexical", "semantic"].includes(rule.action),
49
+ "Each file rule requires pattern and action (exclude, lexical, semantic).",
50
+ );
51
+ invariant(
52
+ typeof rule.pattern === "string" &&
53
+ rule.pattern.length > 0 &&
54
+ rule.pattern.length <= 512 &&
55
+ !/[\\\x00-\x1f\x7f\[\]{}!]/.test(rule.pattern) &&
56
+ rule.pattern
57
+ .split("/")
58
+ .every(
59
+ (part) =>
60
+ part &&
61
+ part !== "." &&
62
+ part !== ".." &&
63
+ (!part.includes("**") || part === "**"),
64
+ ),
65
+ "File policy patterns must be relative paths using only *, ?, and whole-segment ** wildcards.",
66
+ );
67
+ glob(rule.pattern);
68
+ return { pattern: rule.pattern, action: rule.action };
69
+ });
70
+ return { version: 1, rules };
71
+ }
72
+
73
+ /** These bodies cannot be re-enabled by repository rules. This is not a secret scanner. */
74
+ export function hardExclusion(path: string): string | undefined {
75
+ const name = path.split("/").at(-1)!;
76
+ if (
77
+ (/^\.env(?:\.|$)/i.test(name) &&
78
+ !/\.(example|sample|template)$/i.test(name)) ||
79
+ /^(\.npmrc|\.pypirc|\.netrc|_netrc|id_rsa|id_dsa|id_ecdsa|id_ed25519)$/i.test(
80
+ name,
81
+ ) ||
82
+ /(^|\/)\.(aws|ssh)(\/|$)/i.test(path)
83
+ )
84
+ return "credential-file";
85
+ if (
86
+ /\.(pem|key|crt|cer|csr|der|p12|pfx|p7b|p7c|jks|keystore|srl)$/i.test(path)
87
+ )
88
+ return "crypto-material";
89
+ if (
90
+ /\.(svgz?|png|apng|jpe?g|jfif|gif|webp|avif|bmp|dib|tiff?|ico|icns|heic|heif|jxl|psd|ai|eps|pnm|pbm|pgm|ppm|xbm|xpm)$/i.test(
91
+ path,
92
+ )
93
+ )
94
+ return "image-extension";
95
+ if (
96
+ /\.(pdf|zip|gz|bz2|xz|7z|tar|rar|jar|class|woff2?|ttf|otf|mp[34]|wav|ogg|mov|exe|dll|so|dylib|wasm|pyc|pyo|o|a|sqlite3?|db)$/i.test(
97
+ path,
98
+ )
99
+ )
100
+ return "binary-extension";
101
+ return undefined;
102
+ }
103
+
104
+ export function privateKeyContent(source: string): boolean {
105
+ return /(?:^|\r?\n)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----\r?\n/.test(
106
+ source,
107
+ );
108
+ }
109
+
110
+ /** Explicit rules override defaults, in order; the final matching rule wins. */
111
+ export function fileDecision(
112
+ path: string,
113
+ policy: FilePolicy,
114
+ source?: string,
115
+ ): FileDecision {
116
+ let decision: FileDecision = { action: "semantic", reason: "source" };
117
+ if (
118
+ /(^|\/)(node_modules|vendor|vendored|third_party|third-party|dist|build|target|coverage|\.git|\.next|\.nuxt|\.venv|venv|__pycache__|\.cache|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|\.nox|\.srcx)(\/|$)/i.test(
119
+ path,
120
+ )
121
+ )
122
+ decision = { action: "exclude", reason: "dependency-or-build-output" };
123
+ else if (/\.(min\.(js|css)|map)$/i.test(path))
124
+ decision = { action: "exclude", reason: "generated-output" };
125
+ else if (/\.(log|log\.\d+)$/i.test(path))
126
+ decision = { action: "exclude", reason: "log-output" };
127
+ else if (
128
+ /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|go\.sum|[^/]+\.lock|packages\.lock\.json|Package\.resolved)$/i.test(
129
+ path,
130
+ )
131
+ )
132
+ decision = { action: "lexical", reason: "lockfile" };
133
+ else if (
134
+ /(^|\/)(licen[sc]e|copying|copyright|notice|authors|contributors)([.-][^/]*)?$/i.test(
135
+ path,
136
+ )
137
+ )
138
+ decision = { action: "lexical", reason: "legal-or-attribution" };
139
+ else if (
140
+ /\.(csv|tsv|jsonl|ndjson|snap)$/i.test(path) ||
141
+ /(^|\/)__snapshots__\//i.test(path)
142
+ )
143
+ decision = { action: "lexical", reason: "data-or-snapshot" };
144
+ else if (
145
+ /\.(pb\.go|generated\.[^/]+|g\.[^/]+)$/i.test(path) ||
146
+ /_pb2(?:_grpc)?\.py$/i.test(path) ||
147
+ (source !== undefined &&
148
+ /(?:^|\n)[ \t]*(?:\/\/|#|\/\*|\*|<!--)[^\r\n]*(?:code generated[^\r\n]*do not edit|(?:auto[- ]?generated|generated by)[^\r\n]*do not edit|do not edit[^\r\n]*generated|@generated(?:[ \t]|$))/i.test(
149
+ Buffer.from(source, "utf8").subarray(0, 8192).toString("utf8"),
150
+ ))
151
+ )
152
+ decision = { action: "lexical", reason: "generated-source" };
153
+ for (const rule of policy.rules)
154
+ if (glob(rule.pattern).test(path))
155
+ decision = { action: rule.action, reason: `file-rule:${rule.action}` };
156
+ return decision;
157
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,205 @@
1
+ import { readFileSync } from "node:fs";
2
+ import {
3
+ lstat,
4
+ mkdir,
5
+ readFile,
6
+ readdir,
7
+ realpath,
8
+ rename,
9
+ rmdir,
10
+ unlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join, resolve } from "node:path";
15
+ import { hash, invariant } from "./common.js";
16
+
17
+ export const SKILL_AGENTS = ["codex", "claude"] as const;
18
+ export const SKILL_SCOPES = ["user", "project"] as const;
19
+ type Agent = (typeof SKILL_AGENTS)[number];
20
+ type Scope = (typeof SKILL_SCOPES)[number];
21
+ type Operation = "install" | "update" | "remove" | "status";
22
+ const name = "srcx-search";
23
+ const owner = "@functional-systems/srcx";
24
+ const pkg = JSON.parse(
25
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
26
+ );
27
+ const marker = "<!-- srcx-install ";
28
+
29
+ async function stat(path: string) {
30
+ try {
31
+ return await lstat(path);
32
+ } catch (e) {
33
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined;
34
+ throw e;
35
+ }
36
+ }
37
+ async function directory(path: string, create = false): Promise<boolean> {
38
+ if (create) {
39
+ try {
40
+ await mkdir(path);
41
+ } catch (e) {
42
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
43
+ }
44
+ }
45
+ const s = await stat(path);
46
+ invariant(
47
+ !s || (s.isDirectory() && !s.isSymbolicLink()),
48
+ `Expected a directory, not a file or symlink: ${path}`,
49
+ );
50
+ return !!s;
51
+ }
52
+
53
+ export async function manageSkill(
54
+ operation: Operation,
55
+ options: {
56
+ agent: Agent;
57
+ scope: Scope;
58
+ path?: string;
59
+ },
60
+ userHome = homedir(),
61
+ ) {
62
+ invariant(
63
+ SKILL_AGENTS.includes(options.agent),
64
+ "Choose --agent codex or claude.",
65
+ );
66
+ invariant(
67
+ SKILL_SCOPES.includes(options.scope),
68
+ "Choose --scope user or project.",
69
+ );
70
+ invariant(
71
+ options.scope === "project" || options.path === undefined,
72
+ "--path requires --scope project.",
73
+ );
74
+ // Resolve the chosen root, then refuse redirected managed path components.
75
+ const root = await realpath(
76
+ options.scope === "user" ? userHome : resolve(options.path ?? "."),
77
+ );
78
+ invariant(
79
+ (await lstat(root)).isDirectory(),
80
+ "Skill scope root must be a directory.",
81
+ );
82
+ const dirs = [join(root, options.agent === "codex" ? ".agents" : ".claude")];
83
+ dirs.push(join(dirs[0]!, "skills"));
84
+ const parent = dirs[1]!,
85
+ target = join(parent, name),
86
+ file = join(target, "SKILL.md");
87
+ const bundled = await readFile(
88
+ new URL("../skills/srcx-search/SKILL.md", import.meta.url),
89
+ "utf8",
90
+ );
91
+ const bundledHash = hash(bundled);
92
+ const info = {
93
+ agent: options.agent,
94
+ scope: options.scope,
95
+ path: target,
96
+ bundledVersion: pkg.version as string,
97
+ };
98
+ const encoded = `${bundled}${marker}${JSON.stringify({ package: owner, version: pkg.version, sha256: bundledHash })} -->\n`;
99
+ async function inspect() {
100
+ if (!(await directory(target))) return { status: "not-installed" as const };
101
+ const entries = await readdir(target);
102
+ const s = await stat(file);
103
+ if (!s?.isFile() || s.isSymbolicLink())
104
+ return { status: "unmanaged" as const };
105
+ const text = await readFile(file, "utf8");
106
+ const at = text.lastIndexOf(marker);
107
+ let installed: { package?: unknown; version?: unknown; sha256?: unknown };
108
+ try {
109
+ installed = JSON.parse(text.slice(at + marker.length, -5));
110
+ } catch {
111
+ return { status: "unmanaged" as const };
112
+ }
113
+ if (
114
+ at < 0 ||
115
+ !text.endsWith(" -->\n") ||
116
+ installed?.package !== owner ||
117
+ typeof installed.version !== "string" ||
118
+ !installed.version ||
119
+ typeof installed.sha256 !== "string" ||
120
+ !/^[a-f0-9]{64}$/.test(installed.sha256)
121
+ ) {
122
+ return { status: "unmanaged" as const };
123
+ }
124
+ const status =
125
+ entries.length !== 1 || hash(text.slice(0, at)) !== installed.sha256
126
+ ? "modified"
127
+ : installed.sha256 === bundledHash && installed.version === pkg.version
128
+ ? "current"
129
+ : "update-available";
130
+ return { status, installedVersion: installed.version };
131
+ }
132
+ // Status and absent removals are read-only, including missing parent directories.
133
+ for (const dir of dirs) {
134
+ if (!(await directory(dir, operation === "install"))) {
135
+ invariant(
136
+ operation !== "update",
137
+ "Skill is not installed; run skills install first.",
138
+ );
139
+ return { ...info, status: "not-installed" };
140
+ }
141
+ }
142
+ if (operation === "status" || operation === "remove") {
143
+ const current = await inspect();
144
+ if (operation === "status" || current.status === "not-installed")
145
+ return { ...info, ...current };
146
+ }
147
+ const lock = join(parent, `.${name}.srcx-lock`);
148
+ try {
149
+ await mkdir(lock);
150
+ } catch (e) {
151
+ if ((e as NodeJS.ErrnoException).code === "EEXIST")
152
+ throw new Error(
153
+ `Another skill operation may be active. Retry later; inspect a stale lock before removing it: ${lock}`,
154
+ );
155
+ throw e;
156
+ }
157
+ try {
158
+ // Recheck after locking before changing an existing installation.
159
+ const current = await inspect();
160
+ invariant(
161
+ current.status !== "unmanaged" && current.status !== "modified",
162
+ `Existing skill is ${current.status}; preserve or move it before ${operation}: ${target}`,
163
+ );
164
+ if (operation === "remove") {
165
+ if (current.status === "not-installed")
166
+ return { ...info, status: "not-installed" };
167
+ await unlink(file);
168
+ await rmdir(target); // Never recursively remove unrecognized files.
169
+ return { ...info, status: "removed" };
170
+ }
171
+ if (current.status === "current") return { ...info, ...current };
172
+ invariant(
173
+ operation !== "update" || current.status !== "not-installed",
174
+ "Skill is not installed; run skills install first.",
175
+ );
176
+ invariant(
177
+ operation !== "install" || current.status === "not-installed",
178
+ "A different bundled skill is installed; run skills update.",
179
+ );
180
+ // One file carries both instructions and provenance, so updates cannot tear
181
+ // a separate ownership manifest away from the installed content.
182
+ const temp = join(lock, "SKILL.md");
183
+ await writeFile(temp, encoded, { flag: "wx", mode: 0o644 });
184
+ const fresh = current.status === "not-installed";
185
+ if (fresh) await mkdir(target);
186
+ try {
187
+ await rename(temp, file);
188
+ } catch (e) {
189
+ if (fresh) await rmdir(target);
190
+ throw e;
191
+ }
192
+ return {
193
+ ...info,
194
+ status: fresh ? "installed" : "updated",
195
+ installedVersion: pkg.version as string,
196
+ };
197
+ } finally {
198
+ try {
199
+ await unlink(join(lock, "SKILL.md"));
200
+ } catch (e) {
201
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
202
+ }
203
+ await rmdir(lock);
204
+ }
205
+ }