@mafdet/cli 0.3.0
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 +86 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/README.zh-CN.md +148 -0
- package/dist/agent/approvals.d.ts +38 -0
- package/dist/agent/approvals.d.ts.map +1 -0
- package/dist/agent/approvals.js +53 -0
- package/dist/agent/command-policy.d.ts +36 -0
- package/dist/agent/command-policy.d.ts.map +1 -0
- package/dist/agent/command-policy.js +133 -0
- package/dist/agent/loop.d.ts +107 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +242 -0
- package/dist/agent/session.d.ts +62 -0
- package/dist/agent/session.d.ts.map +1 -0
- package/dist/agent/session.js +290 -0
- package/dist/agent/tools/read-only.d.ts +15 -0
- package/dist/agent/tools/read-only.d.ts.map +1 -0
- package/dist/agent/tools/read-only.js +254 -0
- package/dist/agent/tools/run-command.d.ts +20 -0
- package/dist/agent/tools/run-command.d.ts.map +1 -0
- package/dist/agent/tools/run-command.js +116 -0
- package/dist/agent/tools/types.d.ts +46 -0
- package/dist/agent/tools/types.d.ts.map +1 -0
- package/dist/agent/tools/types.js +16 -0
- package/dist/agent/tools/write.d.ts +3 -0
- package/dist/agent/tools/write.d.ts.map +1 -0
- package/dist/agent/tools/write.js +68 -0
- package/dist/api/account.d.ts +39 -0
- package/dist/api/account.d.ts.map +1 -0
- package/dist/api/account.js +39 -0
- package/dist/api/errors.d.ts +38 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +149 -0
- package/dist/api/http.d.ts +63 -0
- package/dist/api/http.d.ts.map +1 -0
- package/dist/api/http.js +207 -0
- package/dist/api/models.d.ts +68 -0
- package/dist/api/models.d.ts.map +1 -0
- package/dist/api/models.js +91 -0
- package/dist/api/sse-parser.d.ts +72 -0
- package/dist/api/sse-parser.d.ts.map +1 -0
- package/dist/api/sse-parser.js +163 -0
- package/dist/commands/account.d.ts +4 -0
- package/dist/commands/account.d.ts.map +1 -0
- package/dist/commands/account.js +103 -0
- package/dist/commands/agent.d.ts +10 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +268 -0
- package/dist/commands/chat.d.ts +13 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +191 -0
- package/dist/commands/config.d.ts +15 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +74 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +157 -0
- package/dist/commands/embed.d.ts +7 -0
- package/dist/commands/embed.d.ts.map +1 -0
- package/dist/commands/embed.js +105 -0
- package/dist/commands/models.d.ts +4 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/commands/models.js +151 -0
- package/dist/commands/runtime.d.ts +29 -0
- package/dist/commands/runtime.d.ts.map +1 -0
- package/dist/commands/runtime.js +56 -0
- package/dist/commands/speech.d.ts +7 -0
- package/dist/commands/speech.d.ts.map +1 -0
- package/dist/commands/speech.js +111 -0
- package/dist/config/config.d.ts +57 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +129 -0
- package/dist/config/load.d.ts +20 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +48 -0
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +26 -0
- package/dist/filesystem/diff.d.ts +12 -0
- package/dist/filesystem/diff.d.ts.map +1 -0
- package/dist/filesystem/diff.js +109 -0
- package/dist/filesystem/sensitive-files.d.ts +19 -0
- package/dist/filesystem/sensitive-files.d.ts.map +1 -0
- package/dist/filesystem/sensitive-files.js +46 -0
- package/dist/filesystem/workspace.d.ts +92 -0
- package/dist/filesystem/workspace.d.ts.map +1 -0
- package/dist/filesystem/workspace.js +264 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/output/exit-codes.d.ts +25 -0
- package/dist/output/exit-codes.d.ts.map +1 -0
- package/dist/output/exit-codes.js +23 -0
- package/dist/output/format.d.ts +28 -0
- package/dist/output/format.d.ts.map +1 -0
- package/dist/output/format.js +56 -0
- package/dist/output/money.d.ts +16 -0
- package/dist/output/money.d.ts.map +1 -0
- package/dist/output/money.js +35 -0
- package/dist/output/write-file.d.ts +11 -0
- package/dist/output/write-file.d.ts.map +1 -0
- package/dist/output/write-file.js +38 -0
- package/dist/security/redact.d.ts +16 -0
- package/dist/security/redact.d.ts.map +1 -0
- package/dist/security/redact.js +61 -0
- package/package.json +51 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Plan §7.4. */
|
|
2
|
+
export declare const MAX_FILE_BYTES: number;
|
|
3
|
+
export declare const MAX_TOOL_OUTPUT_BYTES: number;
|
|
4
|
+
export declare class WorkspaceError extends Error {
|
|
5
|
+
readonly exitCode: 5;
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface ResolvedPath {
|
|
9
|
+
/** Absolute path on disk, with every symlink already followed. */
|
|
10
|
+
absolute: string;
|
|
11
|
+
/** POSIX-separated path relative to the workspace root. */
|
|
12
|
+
relative: string;
|
|
13
|
+
exists: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface FileRead {
|
|
16
|
+
content: string;
|
|
17
|
+
/** Content hash at read time; an edit must re-check it before writing. */
|
|
18
|
+
hash: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The boundary every filesystem tool goes through.
|
|
23
|
+
*
|
|
24
|
+
* Containment is decided on *real* paths - the root and the target are both
|
|
25
|
+
* resolved through `realpath` first - so a symlink or a Windows junction
|
|
26
|
+
* pointing outside cannot smuggle a path past a string comparison. For a file
|
|
27
|
+
* that does not exist yet, the nearest existing ancestor is resolved instead,
|
|
28
|
+
* which is what stops `create_file` from following a symlinked directory out
|
|
29
|
+
* of the workspace.
|
|
30
|
+
*
|
|
31
|
+
* Two Windows behaviours this leans on, verified by reading rather than by
|
|
32
|
+
* running (no Windows machine here, see the E2.7 note in the plan):
|
|
33
|
+
* `realpath` resolves junctions and 8.3 short names such as PROGRA~1 to their
|
|
34
|
+
* long form, and `path.relative` compares case-insensitively on win32, so a
|
|
35
|
+
* differently-cased path inside the workspace is not mistaken for an escape.
|
|
36
|
+
*/
|
|
37
|
+
export declare class Workspace {
|
|
38
|
+
readonly root: string;
|
|
39
|
+
private constructor();
|
|
40
|
+
static open(root: string): Workspace;
|
|
41
|
+
/**
|
|
42
|
+
* Validate a model-supplied path. Throws unless it lands inside the
|
|
43
|
+
* workspace and is not on the sensitive list.
|
|
44
|
+
*/
|
|
45
|
+
resolvePath(input: string): ResolvedPath;
|
|
46
|
+
/** Read a text file, refusing binaries and anything oversized. */
|
|
47
|
+
readTextFile(input: string): FileRead;
|
|
48
|
+
/** True when the path is inside and allowed; never throws. */
|
|
49
|
+
allows(input: string): boolean;
|
|
50
|
+
private assertInside;
|
|
51
|
+
/**
|
|
52
|
+
* realpath the deepest part of the path that exists. A path whose leaf is
|
|
53
|
+
* missing is normal (creating a file); a path whose *parent* is a symlink
|
|
54
|
+
* out of the tree is not, and only resolving the ancestor reveals it.
|
|
55
|
+
*/
|
|
56
|
+
private realpathOfNearestExisting;
|
|
57
|
+
}
|
|
58
|
+
export declare function hashOf(content: Buffer | string): string;
|
|
59
|
+
export interface PlannedEdit {
|
|
60
|
+
/** Workspace-relative path. */
|
|
61
|
+
path: string;
|
|
62
|
+
/** Unified diff of what would change, for the approval prompt. */
|
|
63
|
+
diff: string;
|
|
64
|
+
/** Applied only after approval. */
|
|
65
|
+
apply: () => void;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Plan an exact-match replacement (plan §7.2).
|
|
69
|
+
*
|
|
70
|
+
* The edit is only prepared here; nothing touches the disk until `apply()` is
|
|
71
|
+
* called, which is what lets an approval prompt sit between the two with no
|
|
72
|
+
* side effect if the user says no.
|
|
73
|
+
*
|
|
74
|
+
* Three conditions, all of which produce a structured error the model can act
|
|
75
|
+
* on rather than a silent overwrite:
|
|
76
|
+
* - the file's content still hashes to what the model read
|
|
77
|
+
* - `oldText` occurs exactly once
|
|
78
|
+
* - the result actually differs
|
|
79
|
+
*/
|
|
80
|
+
export declare function planEdit(workspace: Workspace, input: {
|
|
81
|
+
path: string;
|
|
82
|
+
oldText: string;
|
|
83
|
+
newText: string;
|
|
84
|
+
/** Hash from the read that produced `oldText`, when the caller has one. */
|
|
85
|
+
expectedHash?: string;
|
|
86
|
+
}): PlannedEdit;
|
|
87
|
+
/** Plan a new file. Refuses to touch anything that already exists. */
|
|
88
|
+
export declare function planCreate(workspace: Workspace, input: {
|
|
89
|
+
path: string;
|
|
90
|
+
content: string;
|
|
91
|
+
}): PlannedEdit;
|
|
92
|
+
//# sourceMappingURL=workspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../../src/filesystem/workspace.ts"],"names":[],"mappings":"AAkBA,iBAAiB;AACjB,eAAO,MAAM,cAAc,QAAa,CAAC;AACzC,eAAO,MAAM,qBAAqB,QAAc,CAAC;AAEjD,qBAAa,cAAe,SAAQ,KAAK;IACvC,QAAQ,CAAC,QAAQ,IAAwB;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,YAAY;IAC3B,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,SAAS;IACA,QAAQ,CAAC,IAAI,EAAE,MAAM;IAAzC,OAAO;IAEP,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS;IAapC;;;OAGG;IACH,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY;IAmCxC,kEAAkE;IAClE,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ;IA8BrC,8DAA8D;IAC9D,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAS9B,OAAO,CAAC,YAAY;IAYpB;;;;OAIG;IACH,OAAO,CAAC,yBAAyB;CAuBlC;AAED,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAEvD;AAMD,MAAM,WAAW,WAAW;IAC1B,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,QAAQ,CACtB,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE;IACL,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GACA,WAAW,CA0Db;AAED,sEAAsE;AACtE,wBAAgB,UAAU,CACxB,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACvC,WAAW,CAsBb"}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { unifiedDiff } from "./diff.js";
|
|
5
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
6
|
+
import { classifySensitivePath } from "./sensitive-files.js";
|
|
7
|
+
/** Plan §7.4. */
|
|
8
|
+
export const MAX_FILE_BYTES = 512 * 1024;
|
|
9
|
+
export const MAX_TOOL_OUTPUT_BYTES = 1024 * 1024;
|
|
10
|
+
export class WorkspaceError extends Error {
|
|
11
|
+
exitCode = ExitCode.LocalDenied;
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "WorkspaceError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The boundary every filesystem tool goes through.
|
|
19
|
+
*
|
|
20
|
+
* Containment is decided on *real* paths - the root and the target are both
|
|
21
|
+
* resolved through `realpath` first - so a symlink or a Windows junction
|
|
22
|
+
* pointing outside cannot smuggle a path past a string comparison. For a file
|
|
23
|
+
* that does not exist yet, the nearest existing ancestor is resolved instead,
|
|
24
|
+
* which is what stops `create_file` from following a symlinked directory out
|
|
25
|
+
* of the workspace.
|
|
26
|
+
*
|
|
27
|
+
* Two Windows behaviours this leans on, verified by reading rather than by
|
|
28
|
+
* running (no Windows machine here, see the E2.7 note in the plan):
|
|
29
|
+
* `realpath` resolves junctions and 8.3 short names such as PROGRA~1 to their
|
|
30
|
+
* long form, and `path.relative` compares case-insensitively on win32, so a
|
|
31
|
+
* differently-cased path inside the workspace is not mistaken for an escape.
|
|
32
|
+
*/
|
|
33
|
+
export class Workspace {
|
|
34
|
+
root;
|
|
35
|
+
constructor(root) {
|
|
36
|
+
this.root = root;
|
|
37
|
+
}
|
|
38
|
+
static open(root) {
|
|
39
|
+
let real;
|
|
40
|
+
try {
|
|
41
|
+
real = realpathSync(resolve(root));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new WorkspaceError(`workspace root does not exist: ${root}`);
|
|
45
|
+
}
|
|
46
|
+
if (!statSync(real).isDirectory()) {
|
|
47
|
+
throw new WorkspaceError(`workspace root is not a directory: ${root}`);
|
|
48
|
+
}
|
|
49
|
+
return new Workspace(real);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate a model-supplied path. Throws unless it lands inside the
|
|
53
|
+
* workspace and is not on the sensitive list.
|
|
54
|
+
*/
|
|
55
|
+
resolvePath(input) {
|
|
56
|
+
if (input.length === 0)
|
|
57
|
+
throw new WorkspaceError("empty path");
|
|
58
|
+
// A NUL truncates the path in the OS layer: "a\0/../../etc" opens "a".
|
|
59
|
+
if (input.includes("\0"))
|
|
60
|
+
throw new WorkspaceError("path contains a NUL byte");
|
|
61
|
+
const candidate = isAbsolute(input)
|
|
62
|
+
? resolve(input)
|
|
63
|
+
: resolve(this.root, input);
|
|
64
|
+
// First pass on the lexical path. Cheap, and rejects the obvious `../..`.
|
|
65
|
+
this.assertInside(candidate, input);
|
|
66
|
+
// Second pass on the real path: this is the one that catches symlinks and
|
|
67
|
+
// junctions, including a link created after the first check would have
|
|
68
|
+
// passed.
|
|
69
|
+
const real = this.realpathOfNearestExisting(candidate);
|
|
70
|
+
this.assertInside(real.path, input);
|
|
71
|
+
const rel = toPosix(relative(this.root, real.exists ? real.path : candidate));
|
|
72
|
+
const verdict = classifySensitivePath(rel);
|
|
73
|
+
if (verdict.denied) {
|
|
74
|
+
throw new WorkspaceError(`refusing to touch ${rel}: ${verdict.reason}. Authorise that exact path if you really need it.`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
absolute: real.exists ? real.path : candidate,
|
|
78
|
+
relative: rel,
|
|
79
|
+
exists: real.exists,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Read a text file, refusing binaries and anything oversized. */
|
|
83
|
+
readTextFile(input) {
|
|
84
|
+
const target = this.resolvePath(input);
|
|
85
|
+
if (!target.exists) {
|
|
86
|
+
throw new WorkspaceError(`no such file: ${target.relative}`);
|
|
87
|
+
}
|
|
88
|
+
const stats = statSync(target.absolute);
|
|
89
|
+
if (stats.isDirectory()) {
|
|
90
|
+
throw new WorkspaceError(`${target.relative} is a directory`);
|
|
91
|
+
}
|
|
92
|
+
if (stats.size > MAX_FILE_BYTES) {
|
|
93
|
+
throw new WorkspaceError(`${target.relative} is ${stats.size} bytes, over the ${MAX_FILE_BYTES} byte limit`);
|
|
94
|
+
}
|
|
95
|
+
const buffer = readFileSync(target.absolute);
|
|
96
|
+
// A NUL in the first block is the usual signal for a binary; sending one
|
|
97
|
+
// to a model wastes context and produces nonsense.
|
|
98
|
+
if (buffer.subarray(0, 8000).includes(0)) {
|
|
99
|
+
throw new WorkspaceError(`${target.relative} looks like a binary file`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
content: buffer.toString("utf8"),
|
|
103
|
+
hash: hashOf(buffer),
|
|
104
|
+
bytes: buffer.byteLength,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** True when the path is inside and allowed; never throws. */
|
|
108
|
+
allows(input) {
|
|
109
|
+
try {
|
|
110
|
+
this.resolvePath(input);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
assertInside(candidate, original) {
|
|
118
|
+
const rel = relative(this.root, candidate);
|
|
119
|
+
const escapes = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
120
|
+
// rel === "" means the path IS the root, which is inside.
|
|
121
|
+
if (escapes) {
|
|
122
|
+
throw new WorkspaceError(`${original} is outside the workspace (${this.root})`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* realpath the deepest part of the path that exists. A path whose leaf is
|
|
127
|
+
* missing is normal (creating a file); a path whose *parent* is a symlink
|
|
128
|
+
* out of the tree is not, and only resolving the ancestor reveals it.
|
|
129
|
+
*/
|
|
130
|
+
realpathOfNearestExisting(candidate) {
|
|
131
|
+
if (existsSync(candidate)) {
|
|
132
|
+
return { path: realpathSync(candidate), exists: true };
|
|
133
|
+
}
|
|
134
|
+
let parent = dirname(candidate);
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
while (!existsSync(parent)) {
|
|
137
|
+
if (seen.has(parent))
|
|
138
|
+
break;
|
|
139
|
+
seen.add(parent);
|
|
140
|
+
const next = dirname(parent);
|
|
141
|
+
if (next === parent)
|
|
142
|
+
break;
|
|
143
|
+
parent = next;
|
|
144
|
+
}
|
|
145
|
+
if (!existsSync(parent))
|
|
146
|
+
return { path: candidate, exists: false };
|
|
147
|
+
// Re-attach the missing tail to the resolved ancestor.
|
|
148
|
+
const tail = relative(parent, candidate);
|
|
149
|
+
return { path: resolve(realpathSync(parent), tail), exists: false };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function hashOf(content) {
|
|
153
|
+
return createHash("sha256").update(content).digest("hex");
|
|
154
|
+
}
|
|
155
|
+
function toPosix(value) {
|
|
156
|
+
return sep === "/" ? value : value.split(sep).join("/");
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Plan an exact-match replacement (plan §7.2).
|
|
160
|
+
*
|
|
161
|
+
* The edit is only prepared here; nothing touches the disk until `apply()` is
|
|
162
|
+
* called, which is what lets an approval prompt sit between the two with no
|
|
163
|
+
* side effect if the user says no.
|
|
164
|
+
*
|
|
165
|
+
* Three conditions, all of which produce a structured error the model can act
|
|
166
|
+
* on rather than a silent overwrite:
|
|
167
|
+
* - the file's content still hashes to what the model read
|
|
168
|
+
* - `oldText` occurs exactly once
|
|
169
|
+
* - the result actually differs
|
|
170
|
+
*/
|
|
171
|
+
export function planEdit(workspace, input) {
|
|
172
|
+
const target = workspace.resolvePath(input.path);
|
|
173
|
+
if (!target.exists)
|
|
174
|
+
throw new WorkspaceError(`no such file: ${target.relative}`);
|
|
175
|
+
const current = workspace.readTextFile(input.path);
|
|
176
|
+
if (input.expectedHash && input.expectedHash !== current.hash) {
|
|
177
|
+
throw new WorkspaceError(`${target.relative} changed since it was read - read it again before editing`);
|
|
178
|
+
}
|
|
179
|
+
// Match against LF text so a model that only ever emits \n still works on a
|
|
180
|
+
// CRLF file; the original ending is restored on write.
|
|
181
|
+
const eol = detectEol(current.content);
|
|
182
|
+
const normalised = eol === "\r\n" ? current.content.split("\r\n").join("\n") : current.content;
|
|
183
|
+
const oldText = normaliseEol(input.oldText);
|
|
184
|
+
const newText = normaliseEol(input.newText);
|
|
185
|
+
if (oldText.length === 0)
|
|
186
|
+
throw new WorkspaceError("old_text must not be empty");
|
|
187
|
+
const occurrences = countOccurrences(normalised, oldText);
|
|
188
|
+
if (occurrences === 0) {
|
|
189
|
+
throw new WorkspaceError(`old_text was not found in ${target.relative} - read the file again and quote it exactly`);
|
|
190
|
+
}
|
|
191
|
+
if (occurrences > 1) {
|
|
192
|
+
throw new WorkspaceError(`old_text appears ${occurrences} times in ${target.relative} - include enough surrounding lines to make it unique`);
|
|
193
|
+
}
|
|
194
|
+
const updated = normalised.replace(oldText, () => newText);
|
|
195
|
+
if (updated === normalised)
|
|
196
|
+
throw new WorkspaceError("the edit would change nothing");
|
|
197
|
+
const diff = unifiedDiff(normalised, updated, { path: target.relative });
|
|
198
|
+
const onDisk = eol === "\r\n" ? updated.split("\n").join("\r\n") : updated;
|
|
199
|
+
const mode = statSync(target.absolute).mode;
|
|
200
|
+
return {
|
|
201
|
+
path: target.relative,
|
|
202
|
+
diff,
|
|
203
|
+
apply: () => {
|
|
204
|
+
// Re-check immediately before writing: the approval prompt is a window
|
|
205
|
+
// in which something else could have touched the file.
|
|
206
|
+
const latest = workspace.readTextFile(input.path);
|
|
207
|
+
if (latest.hash !== current.hash) {
|
|
208
|
+
throw new WorkspaceError(`${target.relative} changed while waiting for approval - nothing was written`);
|
|
209
|
+
}
|
|
210
|
+
atomicWrite(target.absolute, onDisk, mode);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/** Plan a new file. Refuses to touch anything that already exists. */
|
|
215
|
+
export function planCreate(workspace, input) {
|
|
216
|
+
const target = workspace.resolvePath(input.path);
|
|
217
|
+
if (target.exists) {
|
|
218
|
+
throw new WorkspaceError(`${target.relative} already exists - use edit_file to change it`);
|
|
219
|
+
}
|
|
220
|
+
const diff = unifiedDiff("", input.content, { path: target.relative });
|
|
221
|
+
return {
|
|
222
|
+
path: target.relative,
|
|
223
|
+
diff,
|
|
224
|
+
apply: () => {
|
|
225
|
+
if (existsSync(target.absolute)) {
|
|
226
|
+
throw new WorkspaceError(`${target.relative} appeared while waiting for approval - nothing was written`);
|
|
227
|
+
}
|
|
228
|
+
mkdirSync(dirname(target.absolute), { recursive: true });
|
|
229
|
+
atomicWrite(target.absolute, input.content);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** Same-directory temp file plus rename, so a reader never sees a half file. */
|
|
234
|
+
function atomicWrite(absolute, content, mode) {
|
|
235
|
+
const temp = join(dirname(absolute), `.mafdet-${process.pid}-${Date.now()}.tmp`);
|
|
236
|
+
try {
|
|
237
|
+
writeFileSync(temp, content, mode === undefined ? undefined : { mode });
|
|
238
|
+
renameSync(temp, absolute);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
try {
|
|
242
|
+
unlinkSync(temp);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Never created; nothing to remove.
|
|
246
|
+
}
|
|
247
|
+
throw new WorkspaceError(`cannot write ${absolute}: ${error.message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function detectEol(content) {
|
|
251
|
+
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
252
|
+
}
|
|
253
|
+
function normaliseEol(value) {
|
|
254
|
+
return value.includes("\r\n") ? value.split("\r\n").join("\n") : value;
|
|
255
|
+
}
|
|
256
|
+
function countOccurrences(haystack, needle) {
|
|
257
|
+
let count = 0;
|
|
258
|
+
let index = haystack.indexOf(needle);
|
|
259
|
+
while (index !== -1) {
|
|
260
|
+
count += 1;
|
|
261
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
262
|
+
}
|
|
263
|
+
return count;
|
|
264
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAcA,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AA+ClD,wBAAsB,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4DpE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { accountCommand } from "./commands/account.js";
|
|
6
|
+
import { agentCommand } from "./commands/agent.js";
|
|
7
|
+
import { chatCommand } from "./commands/chat.js";
|
|
8
|
+
import { configCommand } from "./commands/config.js";
|
|
9
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
10
|
+
import { embedCommand } from "./commands/embed.js";
|
|
11
|
+
import { speechCommand } from "./commands/speech.js";
|
|
12
|
+
import { modelsCommand } from "./commands/models.js";
|
|
13
|
+
import { ExitCode } from "./output/exit-codes.js";
|
|
14
|
+
// Read from the installed package rather than baking a literal in at build
|
|
15
|
+
// time, so `mafdet --version` can never disagree with what npm installed.
|
|
16
|
+
function version() {
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
19
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
20
|
+
}
|
|
21
|
+
// Only commands that actually work are listed. The CLI never advertises a
|
|
22
|
+
// surface it cannot serve — a user copying from --help must not hit a stub.
|
|
23
|
+
const HELP = `mafdet - Mafdet AI command line
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
mafdet <command> [options]
|
|
27
|
+
|
|
28
|
+
Commands:
|
|
29
|
+
agent Inspect the workspace with a model (read-only)
|
|
30
|
+
chat Send a prompt to a model
|
|
31
|
+
embed Turn text into a vector
|
|
32
|
+
speech Turn text into an audio file
|
|
33
|
+
models list List the models this key can call
|
|
34
|
+
models show Describe one model
|
|
35
|
+
account balance Wallet balance and bonus
|
|
36
|
+
account usage Requests, tokens and spend over a window
|
|
37
|
+
account subscription Current plan and Playground allowance
|
|
38
|
+
config list Show the resolved settings and where each came from
|
|
39
|
+
doctor Check key, endpoints, model, balance and workspace
|
|
40
|
+
help Show this help
|
|
41
|
+
version Print the version
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
-h, --help Show this help
|
|
45
|
+
-v, --version Print the version
|
|
46
|
+
|
|
47
|
+
Docs: https://docs.mafdet.ai
|
|
48
|
+
`;
|
|
49
|
+
async function readStdin() {
|
|
50
|
+
const chunks = [];
|
|
51
|
+
for await (const chunk of process.stdin)
|
|
52
|
+
chunks.push(chunk);
|
|
53
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
54
|
+
}
|
|
55
|
+
export async function run(argv) {
|
|
56
|
+
const [command] = argv;
|
|
57
|
+
if (command === undefined) {
|
|
58
|
+
// Not an error the user asked for, but not a result either: usage goes to
|
|
59
|
+
// stderr so a bare `mafdet` in a pipeline never pollutes stdout.
|
|
60
|
+
process.stderr.write(HELP);
|
|
61
|
+
return ExitCode.Usage;
|
|
62
|
+
}
|
|
63
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
64
|
+
process.stdout.write(HELP);
|
|
65
|
+
return ExitCode.Ok;
|
|
66
|
+
}
|
|
67
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
68
|
+
process.stdout.write(`${version()}\n`);
|
|
69
|
+
return ExitCode.Ok;
|
|
70
|
+
}
|
|
71
|
+
const io = {
|
|
72
|
+
stdout: (text) => process.stdout.write(text),
|
|
73
|
+
stderr: (text) => process.stderr.write(text),
|
|
74
|
+
version: version(),
|
|
75
|
+
};
|
|
76
|
+
if (command === "agent") {
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
process.on("SIGINT", () => controller.abort());
|
|
79
|
+
return agentCommand(argv.slice(1), { ...io, signal: controller.signal });
|
|
80
|
+
}
|
|
81
|
+
if (command === "chat") {
|
|
82
|
+
// Ctrl+C must close the connection, not just stop printing: closing it is
|
|
83
|
+
// what tells the gateway to stop generating.
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
process.on("SIGINT", () => controller.abort());
|
|
86
|
+
return chatCommand(argv.slice(1), {
|
|
87
|
+
...io,
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
readStdin: readStdin,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (command === "embed") {
|
|
93
|
+
return embedCommand(argv.slice(1), { ...io, readStdin });
|
|
94
|
+
}
|
|
95
|
+
if (command === "speech") {
|
|
96
|
+
return speechCommand(argv.slice(1), { ...io, readStdin });
|
|
97
|
+
}
|
|
98
|
+
if (command === "doctor")
|
|
99
|
+
return doctorCommand(argv.slice(1), io);
|
|
100
|
+
if (command === "config")
|
|
101
|
+
return configCommand(argv.slice(1), io);
|
|
102
|
+
if (command === "models")
|
|
103
|
+
return modelsCommand(argv.slice(1), io);
|
|
104
|
+
if (command === "account")
|
|
105
|
+
return accountCommand(argv.slice(1), io);
|
|
106
|
+
process.stderr.write(`mafdet: unknown command '${command}'\nRun 'mafdet help' to see available commands.\n`);
|
|
107
|
+
return ExitCode.Usage;
|
|
108
|
+
}
|
|
109
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process exit codes. Published contract (plan §4.5): scripts branch on these,
|
|
3
|
+
* so a value never changes meaning once released — new failure kinds get a new
|
|
4
|
+
* code or reuse the closest existing one.
|
|
5
|
+
*/
|
|
6
|
+
export declare const ExitCode: {
|
|
7
|
+
/** Completed. */
|
|
8
|
+
readonly Ok: 0;
|
|
9
|
+
/** Something failed that has no more specific code. */
|
|
10
|
+
readonly General: 1;
|
|
11
|
+
/** The command line or the configuration is wrong. */
|
|
12
|
+
readonly Usage: 2;
|
|
13
|
+
/** The key is missing, invalid, or not permitted to do this. */
|
|
14
|
+
readonly Auth: 3;
|
|
15
|
+
/** The model or the API could not serve the request. */
|
|
16
|
+
readonly Unavailable: 4;
|
|
17
|
+
/** A local file or command was refused by the permission model. */
|
|
18
|
+
readonly LocalDenied: 5;
|
|
19
|
+
/** The user cancelled (Ctrl+C, or declined an approval). */
|
|
20
|
+
readonly Cancelled: 6;
|
|
21
|
+
/** A cost, time, or loop ceiling stopped the run. */
|
|
22
|
+
readonly LimitReached: 7;
|
|
23
|
+
};
|
|
24
|
+
export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
|
|
25
|
+
//# sourceMappingURL=exit-codes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exit-codes.d.ts","sourceRoot":"","sources":["../../src/output/exit-codes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,QAAQ;IACnB,iBAAiB;;IAEjB,uDAAuD;;IAEvD,sDAAsD;;IAEtD,gEAAgE;;IAEhE,wDAAwD;;IAExD,mEAAmE;;IAEnE,4DAA4D;;IAE5D,qDAAqD;;CAE7C,CAAC;AAEX,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,OAAO,QAAQ,CAAC,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process exit codes. Published contract (plan §4.5): scripts branch on these,
|
|
3
|
+
* so a value never changes meaning once released — new failure kinds get a new
|
|
4
|
+
* code or reuse the closest existing one.
|
|
5
|
+
*/
|
|
6
|
+
export const ExitCode = {
|
|
7
|
+
/** Completed. */
|
|
8
|
+
Ok: 0,
|
|
9
|
+
/** Something failed that has no more specific code. */
|
|
10
|
+
General: 1,
|
|
11
|
+
/** The command line or the configuration is wrong. */
|
|
12
|
+
Usage: 2,
|
|
13
|
+
/** The key is missing, invalid, or not permitted to do this. */
|
|
14
|
+
Auth: 3,
|
|
15
|
+
/** The model or the API could not serve the request. */
|
|
16
|
+
Unavailable: 4,
|
|
17
|
+
/** A local file or command was refused by the permission model. */
|
|
18
|
+
LocalDenied: 5,
|
|
19
|
+
/** The user cancelled (Ctrl+C, or declined an approval). */
|
|
20
|
+
Cancelled: 6,
|
|
21
|
+
/** A cost, time, or loop ceiling stopped the run. */
|
|
22
|
+
LimitReached: 7,
|
|
23
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering for query commands. Three shapes, one rule: stdout carries the
|
|
3
|
+
* result and nothing else, so `mafdet ... --json | jq` is always valid.
|
|
4
|
+
*/
|
|
5
|
+
export type OutputFormat = "table" | "plain" | "json";
|
|
6
|
+
export interface FormatFlags {
|
|
7
|
+
format: OutputFormat;
|
|
8
|
+
/** Arguments left over after the known flags were consumed. */
|
|
9
|
+
rest: string[];
|
|
10
|
+
}
|
|
11
|
+
/** `--json` and `--plain` are mutually exclusive; table is the default. */
|
|
12
|
+
export declare function parseFormatFlags(argv: readonly string[]): FormatFlags;
|
|
13
|
+
/** One complete JSON document, newline-terminated. */
|
|
14
|
+
export declare function renderJson(value: unknown): string;
|
|
15
|
+
export interface Column<T> {
|
|
16
|
+
header: string;
|
|
17
|
+
value: (row: T) => string;
|
|
18
|
+
/** Right-align numeric columns so they can be scanned down. */
|
|
19
|
+
align?: "left" | "right";
|
|
20
|
+
}
|
|
21
|
+
export declare function renderTable<T>(rows: readonly T[], columns: readonly Column<T>[]): string;
|
|
22
|
+
/**
|
|
23
|
+
* Tab-separated, no header, no padding: the shape `cut`, `awk` and `while read`
|
|
24
|
+
* expect. Stable across releases, unlike the table.
|
|
25
|
+
*/
|
|
26
|
+
export declare function renderPlain<T>(rows: readonly T[], columns: readonly Column<T>[]): string;
|
|
27
|
+
export declare function renderRecord(entries: readonly (readonly [string, string])[]): string;
|
|
28
|
+
//# sourceMappingURL=format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/output/format.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC;AAEtD,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,YAAY,CAAC;IACrB,+DAA+D;IAC/D,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,WAAW,CAiBrE;AAED,sDAAsD;AACtD,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEjD;AAED,MAAM,WAAW,MAAM,CAAC,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,wBAAgB,WAAW,CAAC,CAAC,EAC3B,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,OAAO,EAAE,SAAS,MAAM,CAAC,CAAC,CAAC,EAAE,GAC5B,MAAM,CAoBR;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC3B,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,OAAO,EAAE,SAAS,MAAM,CAAC,CAAC,CAAC,EAAE,GAC5B,MAAM,CAOR;AAED,wBAAgB,YAAY,CAC1B,OAAO,EAAE,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,GAC9C,MAAM,CAOR"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering for query commands. Three shapes, one rule: stdout carries the
|
|
3
|
+
* result and nothing else, so `mafdet ... --json | jq` is always valid.
|
|
4
|
+
*/
|
|
5
|
+
/** `--json` and `--plain` are mutually exclusive; table is the default. */
|
|
6
|
+
export function parseFormatFlags(argv) {
|
|
7
|
+
const rest = [];
|
|
8
|
+
let format = "table";
|
|
9
|
+
let chosen;
|
|
10
|
+
for (const arg of argv) {
|
|
11
|
+
if (arg === "--json" || arg === "--plain") {
|
|
12
|
+
if (chosen && chosen !== arg) {
|
|
13
|
+
throw new Error(`${chosen} and ${arg} cannot be combined`);
|
|
14
|
+
}
|
|
15
|
+
chosen = arg;
|
|
16
|
+
format = arg === "--json" ? "json" : "plain";
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
rest.push(arg);
|
|
20
|
+
}
|
|
21
|
+
return { format, rest };
|
|
22
|
+
}
|
|
23
|
+
/** One complete JSON document, newline-terminated. */
|
|
24
|
+
export function renderJson(value) {
|
|
25
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
26
|
+
}
|
|
27
|
+
export function renderTable(rows, columns) {
|
|
28
|
+
if (rows.length === 0)
|
|
29
|
+
return "";
|
|
30
|
+
const cells = rows.map((row) => columns.map((column) => column.value(row)));
|
|
31
|
+
const widths = columns.map((column, i) => Math.max(column.header.length, ...cells.map((row) => row[i].length)));
|
|
32
|
+
const line = (values) => values
|
|
33
|
+
.map((value, i) => columns[i].align === "right"
|
|
34
|
+
? value.padStart(widths[i])
|
|
35
|
+
: value.padEnd(widths[i]))
|
|
36
|
+
.join(" ")
|
|
37
|
+
.trimEnd();
|
|
38
|
+
return ([line(columns.map((c) => c.header)), ...cells.map(line)].join("\n") + "\n");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Tab-separated, no header, no padding: the shape `cut`, `awk` and `while read`
|
|
42
|
+
* expect. Stable across releases, unlike the table.
|
|
43
|
+
*/
|
|
44
|
+
export function renderPlain(rows, columns) {
|
|
45
|
+
if (rows.length === 0)
|
|
46
|
+
return "";
|
|
47
|
+
return (rows
|
|
48
|
+
.map((row) => columns.map((column) => column.value(row)).join("\t"))
|
|
49
|
+
.join("\n") + "\n");
|
|
50
|
+
}
|
|
51
|
+
export function renderRecord(entries) {
|
|
52
|
+
const width = Math.max(...entries.map(([label]) => label.length));
|
|
53
|
+
return (entries
|
|
54
|
+
.map(([label, value]) => `${label.padEnd(width)} ${value}`)
|
|
55
|
+
.join("\n") + "\n");
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money never becomes a JavaScript number.
|
|
3
|
+
*
|
|
4
|
+
* The API speaks integer micro-USD as decimal strings, and 0.1 + 0.2 is the
|
|
5
|
+
* reason: a float round-trip can turn a balance into a number that does not
|
|
6
|
+
* match the invoice. Everything here is BigInt in, string out.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseMicroUsd(value: string | number | bigint): bigint;
|
|
9
|
+
/**
|
|
10
|
+
* Render micro-USD as dollars. `minimumFractionDigits` keeps small amounts
|
|
11
|
+
* readable ($0.00 is wrong for 1 µ$, so tiny values keep their precision).
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatUsd(microUsd: string | number | bigint, options?: {
|
|
14
|
+
minFractionDigits?: number;
|
|
15
|
+
}): string;
|
|
16
|
+
//# sourceMappingURL=money.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"money.d.ts","sourceRoot":"","sources":["../../src/output/money.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAOrE;AAED;;;GAGG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,EAClC,OAAO,GAAE;IAAE,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAAO,GAC3C,MAAM,CAeR"}
|