@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/CHANGELOG.md +21 -0
- package/DESIGN.md +35 -7
- package/README.md +140 -3
- package/VALIDATION.md +134 -0
- package/dist/build.d.ts +5 -1
- package/dist/build.js +50 -25
- package/dist/build.js.map +1 -1
- package/dist/chunk.d.ts +2 -1
- package/dist/chunk.js +5 -4
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +25 -2
- package/dist/cli.js.map +1 -1
- package/dist/file-policy.d.ts +20 -0
- package/dist/file-policy.js +96 -0
- package/dist/file-policy.js.map +1 -0
- package/dist/skills.d.ts +24 -0
- package/dist/skills.js +155 -0
- package/dist/skills.js.map +1 -0
- package/package.json +5 -4
- package/runtime/grammars/README.md +26 -2
- package/runtime/grammars/tree-sitter-bash.LICENSE +21 -0
- package/runtime/grammars/tree-sitter-bash.wasm +0 -0
- package/skills/srcx-search/SKILL.md +77 -0
- package/src/build.ts +71 -33
- package/src/chunk.ts +6 -6
- package/src/cli.ts +43 -2
- package/src/file-policy.ts +157 -0
- package/src/skills.ts +205 -0
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
|
+
}
|