facult 2.6.0 → 2.7.1
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/README.md +145 -337
- package/package.json +1 -1
- package/src/adapters/codex.ts +1 -1
- package/src/audit/agent.ts +26 -24
- package/src/audit/fix.ts +875 -0
- package/src/audit/index.ts +51 -2
- package/src/audit/safe.ts +596 -0
- package/src/audit/static.ts +151 -34
- package/src/audit/status.ts +21 -0
- package/src/audit/suppressions.ts +266 -0
- package/src/audit/tui.ts +784 -174
- package/src/audit/update-index.ts +4 -17
- package/src/builtin.ts +7 -1
- package/src/cli-ui.ts +375 -0
- package/src/consolidate.ts +151 -55
- package/src/doctor.ts +327 -0
- package/src/global-docs.ts +43 -2
- package/src/index.ts +571 -292
- package/src/manage.ts +931 -88
- package/src/mcp-config.ts +132 -0
- package/src/project-sync.ts +288 -0
- package/src/remote.ts +387 -117
- package/src/trust.ts +119 -11
- package/src/util/git.ts +95 -0
package/src/trust.ts
CHANGED
|
@@ -4,6 +4,13 @@ import type { FacultIndex } from "./index-builder";
|
|
|
4
4
|
import { facultAiIndexPath, facultRootDir } from "./paths";
|
|
5
5
|
|
|
6
6
|
type TrustMode = "trust" | "untrust";
|
|
7
|
+
type TrustTargetKind = "skills" | "mcp";
|
|
8
|
+
|
|
9
|
+
interface TrustArgs {
|
|
10
|
+
all: boolean;
|
|
11
|
+
kind?: TrustTargetKind;
|
|
12
|
+
names: string[];
|
|
13
|
+
}
|
|
7
14
|
|
|
8
15
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
9
16
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
@@ -28,6 +35,34 @@ function parseEntryName(raw: string): { kind: "skill" | "mcp"; name: string } {
|
|
|
28
35
|
return { kind: "skill", name: raw };
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
function collectTargetNames(args: {
|
|
39
|
+
index: FacultIndex;
|
|
40
|
+
all: boolean;
|
|
41
|
+
kind?: TrustTargetKind;
|
|
42
|
+
names: string[];
|
|
43
|
+
}): string[] {
|
|
44
|
+
if (!args.all) {
|
|
45
|
+
return args.names;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (args.kind === "skills") {
|
|
49
|
+
return Object.keys(args.index.skills).sort();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (args.kind === "mcp") {
|
|
53
|
+
return Object.keys(args.index.mcp?.servers ?? {})
|
|
54
|
+
.sort()
|
|
55
|
+
.map((name) => `mcp:${name}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
...Object.keys(args.index.skills).sort(),
|
|
60
|
+
...Object.keys(args.index.mcp?.servers ?? {})
|
|
61
|
+
.sort()
|
|
62
|
+
.map((name) => `mcp:${name}`),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
async function loadIndex(homeDir: string): Promise<FacultIndex> {
|
|
32
67
|
const { path: indexPath } = await ensureAiIndexPath({
|
|
33
68
|
homeDir,
|
|
@@ -71,23 +106,33 @@ function setTrustFields(
|
|
|
71
106
|
|
|
72
107
|
export async function applyTrust({
|
|
73
108
|
names,
|
|
109
|
+
all,
|
|
110
|
+
kind,
|
|
74
111
|
mode,
|
|
75
112
|
homeDir,
|
|
76
113
|
}: {
|
|
77
114
|
names: string[];
|
|
115
|
+
all?: boolean;
|
|
116
|
+
kind?: TrustTargetKind;
|
|
78
117
|
mode: TrustMode;
|
|
79
118
|
homeDir?: string;
|
|
80
119
|
}) {
|
|
81
|
-
if (!names.length) {
|
|
82
|
-
throw new Error("At least one name is required.");
|
|
83
|
-
}
|
|
84
120
|
const home = homeDir ?? homedir();
|
|
85
121
|
|
|
86
122
|
const index = ensureIndexStructure(await loadIndex(home));
|
|
123
|
+
const targetNames = collectTargetNames({
|
|
124
|
+
index,
|
|
125
|
+
all: all === true,
|
|
126
|
+
kind,
|
|
127
|
+
names,
|
|
128
|
+
});
|
|
129
|
+
if (!targetNames.length) {
|
|
130
|
+
throw new Error("No matching entries found.");
|
|
131
|
+
}
|
|
87
132
|
const now = new Date().toISOString();
|
|
88
133
|
|
|
89
134
|
const missing: string[] = [];
|
|
90
|
-
for (const raw of
|
|
135
|
+
for (const raw of targetNames) {
|
|
91
136
|
const { kind, name } = parseEntryName(raw);
|
|
92
137
|
if (kind === "skill") {
|
|
93
138
|
const entry = index.skills[name] as unknown;
|
|
@@ -115,17 +160,44 @@ export async function applyTrust({
|
|
|
115
160
|
}
|
|
116
161
|
|
|
117
162
|
function parseNamesFromArgv(argv: string[]): string[] {
|
|
163
|
+
return parseTrustArgs(argv).names;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseTrustArgs(argv: string[]): TrustArgs {
|
|
118
167
|
const names: string[] = [];
|
|
168
|
+
let all = false;
|
|
169
|
+
|
|
119
170
|
for (const arg of argv) {
|
|
120
171
|
if (!arg) {
|
|
121
172
|
continue;
|
|
122
173
|
}
|
|
174
|
+
if (arg === "--all") {
|
|
175
|
+
all = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
123
178
|
if (arg.startsWith("-")) {
|
|
124
179
|
throw new Error(`Unknown option: ${arg}`);
|
|
125
180
|
}
|
|
126
181
|
names.push(arg);
|
|
127
182
|
}
|
|
128
|
-
|
|
183
|
+
|
|
184
|
+
if (all) {
|
|
185
|
+
if (names.length === 0) {
|
|
186
|
+
return { all: true, names: [] };
|
|
187
|
+
}
|
|
188
|
+
if (names.length === 1 && (names[0] === "skills" || names[0] === "mcp")) {
|
|
189
|
+
return { all: true, kind: names[0], names: [] };
|
|
190
|
+
}
|
|
191
|
+
throw new Error(
|
|
192
|
+
'When using --all, optionally pass only "skills" or "mcp".'
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!names.length) {
|
|
197
|
+
throw new Error("At least one name is required.");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { all: false, names };
|
|
129
201
|
}
|
|
130
202
|
|
|
131
203
|
export async function trustCommand(argv: string[]) {
|
|
@@ -135,13 +207,31 @@ export async function trustCommand(argv: string[]) {
|
|
|
135
207
|
Usage:
|
|
136
208
|
fclt trust <name> [moreNames...]
|
|
137
209
|
fclt trust mcp:<name> [moreNames...]
|
|
210
|
+
fclt trust --all
|
|
211
|
+
fclt trust skills --all
|
|
212
|
+
fclt trust mcp --all
|
|
138
213
|
`);
|
|
139
214
|
return;
|
|
140
215
|
}
|
|
141
216
|
try {
|
|
142
|
-
const
|
|
143
|
-
await applyTrust({
|
|
144
|
-
|
|
217
|
+
const parsed = parseTrustArgs(argv);
|
|
218
|
+
await applyTrust({
|
|
219
|
+
names: parsed.names,
|
|
220
|
+
all: parsed.all,
|
|
221
|
+
kind: parsed.kind,
|
|
222
|
+
mode: "trust",
|
|
223
|
+
});
|
|
224
|
+
if (parsed.all) {
|
|
225
|
+
const targetLabel =
|
|
226
|
+
parsed.kind === "skills"
|
|
227
|
+
? "all skills"
|
|
228
|
+
: parsed.kind === "mcp"
|
|
229
|
+
? "all MCP servers"
|
|
230
|
+
: "all skills and MCP servers";
|
|
231
|
+
console.log(`Marked as trusted: ${targetLabel}`);
|
|
232
|
+
} else {
|
|
233
|
+
console.log(`Marked as trusted: ${parsed.names.join(", ")}`);
|
|
234
|
+
}
|
|
145
235
|
console.log(
|
|
146
236
|
'Note: Trust is an annotation. Run "fclt audit" for security review.'
|
|
147
237
|
);
|
|
@@ -158,13 +248,31 @@ export async function untrustCommand(argv: string[]) {
|
|
|
158
248
|
Usage:
|
|
159
249
|
fclt untrust <name> [moreNames...]
|
|
160
250
|
fclt untrust mcp:<name> [moreNames...]
|
|
251
|
+
fclt untrust --all
|
|
252
|
+
fclt untrust skills --all
|
|
253
|
+
fclt untrust mcp --all
|
|
161
254
|
`);
|
|
162
255
|
return;
|
|
163
256
|
}
|
|
164
257
|
try {
|
|
165
|
-
const
|
|
166
|
-
await applyTrust({
|
|
167
|
-
|
|
258
|
+
const parsed = parseTrustArgs(argv);
|
|
259
|
+
await applyTrust({
|
|
260
|
+
names: parsed.names,
|
|
261
|
+
all: parsed.all,
|
|
262
|
+
kind: parsed.kind,
|
|
263
|
+
mode: "untrust",
|
|
264
|
+
});
|
|
265
|
+
if (parsed.all) {
|
|
266
|
+
const targetLabel =
|
|
267
|
+
parsed.kind === "skills"
|
|
268
|
+
? "all skills"
|
|
269
|
+
: parsed.kind === "mcp"
|
|
270
|
+
? "all MCP servers"
|
|
271
|
+
: "all skills and MCP servers";
|
|
272
|
+
console.log(`Marked as untrusted: ${targetLabel}`);
|
|
273
|
+
} else {
|
|
274
|
+
console.log(`Marked as untrusted: ${parsed.names.join(", ")}`);
|
|
275
|
+
}
|
|
168
276
|
} catch (err) {
|
|
169
277
|
console.error(err instanceof Error ? err.message : String(err));
|
|
170
278
|
process.exitCode = 1;
|
package/src/util/git.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type GitPathExposure =
|
|
5
|
+
| {
|
|
6
|
+
insideRepo: false;
|
|
7
|
+
repoRoot: null;
|
|
8
|
+
state: "outside-repo";
|
|
9
|
+
}
|
|
10
|
+
| {
|
|
11
|
+
insideRepo: true;
|
|
12
|
+
repoRoot: string;
|
|
13
|
+
state: "tracked" | "ignored" | "untracked";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async function runGit(
|
|
17
|
+
args: string[],
|
|
18
|
+
cwd: string
|
|
19
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
20
|
+
const gitBinary = Bun.which("git") ?? "/usr/bin/git";
|
|
21
|
+
const proc = Bun.spawn({
|
|
22
|
+
cmd: [gitBinary, ...args],
|
|
23
|
+
cwd,
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
});
|
|
27
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
28
|
+
new Response(proc.stdout).text(),
|
|
29
|
+
new Response(proc.stderr).text(),
|
|
30
|
+
proc.exited,
|
|
31
|
+
]);
|
|
32
|
+
return { exitCode, stdout, stderr };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function resolveGitRepoRoot(pathValue: string): Promise<string | null> {
|
|
36
|
+
const cwd = dirname(pathValue);
|
|
37
|
+
const result = await runGit(["rev-parse", "--show-toplevel"], cwd);
|
|
38
|
+
if (result.exitCode !== 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const repoRoot = result.stdout.trim();
|
|
42
|
+
if (!repoRoot) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return await realpath(repoRoot).catch(() => repoRoot);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function getGitPathExposure(
|
|
49
|
+
pathValue: string
|
|
50
|
+
): Promise<GitPathExposure> {
|
|
51
|
+
const repoRoot = await resolveGitRepoRoot(pathValue);
|
|
52
|
+
if (!repoRoot) {
|
|
53
|
+
return {
|
|
54
|
+
insideRepo: false,
|
|
55
|
+
repoRoot: null,
|
|
56
|
+
state: "outside-repo",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resolvedPath = await realpath(pathValue).catch(() => pathValue);
|
|
61
|
+
const repoRelativePath = relative(repoRoot, resolvedPath);
|
|
62
|
+
if (
|
|
63
|
+
!repoRelativePath ||
|
|
64
|
+
repoRelativePath === "" ||
|
|
65
|
+
repoRelativePath.startsWith("../")
|
|
66
|
+
) {
|
|
67
|
+
return {
|
|
68
|
+
insideRepo: false,
|
|
69
|
+
repoRoot: null,
|
|
70
|
+
state: "outside-repo",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tracked = await runGit(
|
|
75
|
+
["ls-files", "--error-unmatch", "--", repoRelativePath],
|
|
76
|
+
repoRoot
|
|
77
|
+
);
|
|
78
|
+
if (tracked.exitCode === 0) {
|
|
79
|
+
return {
|
|
80
|
+
insideRepo: true,
|
|
81
|
+
repoRoot,
|
|
82
|
+
state: "tracked",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ignored = await runGit(
|
|
87
|
+
["check-ignore", "-q", "--", repoRelativePath],
|
|
88
|
+
repoRoot
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
insideRepo: true,
|
|
92
|
+
repoRoot,
|
|
93
|
+
state: ignored.exitCode === 0 ? "ignored" : "untracked",
|
|
94
|
+
};
|
|
95
|
+
}
|