toolcraft 0.0.98 → 0.0.100
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/composition.json +1 -1
- package/dist/composition.json +1 -1
- package/dist/file-change-renderer.d.ts +9 -0
- package/dist/file-change-renderer.js +10 -0
- package/dist/human-in-loop/approval-tasks.d.ts +3 -0
- package/dist/human-in-loop/approval-tasks.js +30 -19
- package/dist/human-in-loop/config.js +3 -0
- package/dist/human-in-loop/gate.js +16 -2
- package/dist/human-in-loop/plan-hash.d.ts +13 -0
- package/dist/human-in-loop/plan-hash.js +73 -0
- package/dist/human-in-loop/runner.js +43 -2
- package/dist/human-in-loop/types.d.ts +5 -0
- package/dist/index.compile-check.js +9 -0
- package/dist/index.d.ts +2 -0
- package/node_modules/toolcraft-design/dist/components/file-changes.d.ts +16 -0
- package/node_modules/toolcraft-design/dist/components/file-changes.js +162 -0
- package/node_modules/toolcraft-design/dist/components/index.d.ts +2 -0
- package/node_modules/toolcraft-design/dist/components/index.js +1 -0
- package/node_modules/toolcraft-design/dist/index.d.ts +2 -0
- package/node_modules/toolcraft-design/dist/index.js +1 -0
- package/package.json +6 -2
package/composition.json
CHANGED
package/dist/composition.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type FileChange, type FileChangeDisplayMode } from "toolcraft-design";
|
|
2
|
+
import type { Renderers } from "./index.js";
|
|
3
|
+
export interface FileChangeResult {
|
|
4
|
+
changes: readonly FileChange[];
|
|
5
|
+
}
|
|
6
|
+
export interface FileChangeRendererOptions {
|
|
7
|
+
mode?: FileChangeDisplayMode;
|
|
8
|
+
}
|
|
9
|
+
export declare function createFileChangeRenderers<TResult extends FileChangeResult = FileChangeResult>(options?: FileChangeRendererOptions): Renderers<TResult>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { renderFileChanges } from "toolcraft-design";
|
|
2
|
+
export function createFileChangeRenderers(options = {}) {
|
|
3
|
+
return {
|
|
4
|
+
rich: (result) => {
|
|
5
|
+
process.stdout.write(`${renderFileChanges(result.changes, { mode: options.mode })}\n`);
|
|
6
|
+
},
|
|
7
|
+
markdown: (result) => renderFileChanges(result.changes, { mode: options.mode, format: "markdown" }),
|
|
8
|
+
json: (result) => result
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { OpenTaskListOptions, TaskList, Tasks } from "@poe-code/task-list";
|
|
2
2
|
import type { HumanInLoopPending, HumanInLoopRuntimeOptions } from "./types.js";
|
|
3
|
+
import { type ApprovalPlanValue } from "./plan-hash.js";
|
|
3
4
|
export interface ApprovalPayload {
|
|
4
5
|
approvalId?: string;
|
|
5
6
|
commandPath: string;
|
|
6
7
|
params: Record<string, unknown>;
|
|
7
8
|
message: string;
|
|
8
9
|
declineInputPrompt?: string | null;
|
|
10
|
+
plan?: ApprovalPlanValue;
|
|
11
|
+
planHash?: string;
|
|
9
12
|
enqueuedAt?: string;
|
|
10
13
|
pid?: number | null;
|
|
11
14
|
result?: unknown;
|
|
@@ -2,6 +2,7 @@ import { TaskAlreadyExistsError, TaskNotFoundError, openTaskList } from "@poe-co
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { UserError } from "../user-error.js";
|
|
4
4
|
import { approvalStateMachine } from "./state-machine.js";
|
|
5
|
+
import { isApprovalPlanValue } from "./plan-hash.js";
|
|
5
6
|
const DEFAULT_LIST_NAME = "approvals";
|
|
6
7
|
const openedTaskListsByRuntime = new WeakMap();
|
|
7
8
|
const validatedListsByRuntime = new WeakMap();
|
|
@@ -84,28 +85,36 @@ function isListValidated(runtimeOptions, listName) {
|
|
|
84
85
|
}
|
|
85
86
|
function createApprovalRecord(payload, enqueuedAt) {
|
|
86
87
|
const approvalId = `${enqueuedAt.slice(0, 19).replaceAll(":", "-")}-${randomBytes(3).toString("hex")}`;
|
|
87
|
-
|
|
88
|
+
const metadata = {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
approvalId,
|
|
91
|
+
commandPath: payload.commandPath,
|
|
92
|
+
params: payload.params,
|
|
93
|
+
message: payload.message,
|
|
94
|
+
declineInputPrompt: payload.declineInputPrompt ?? null,
|
|
95
|
+
enqueuedAt,
|
|
96
|
+
pid: null,
|
|
97
|
+
result: null,
|
|
98
|
+
error: null
|
|
99
|
+
};
|
|
100
|
+
const pending = {
|
|
101
|
+
status: "pending-approval",
|
|
102
|
+
approvalId,
|
|
103
|
+
message: payload.message,
|
|
104
|
+
enqueuedAt
|
|
105
|
+
};
|
|
106
|
+
const approval = {
|
|
88
107
|
approvalId,
|
|
89
108
|
name: `${payload.commandPath} (${enqueuedAt})`,
|
|
90
|
-
metadata
|
|
91
|
-
|
|
92
|
-
approvalId,
|
|
93
|
-
commandPath: payload.commandPath,
|
|
94
|
-
params: payload.params,
|
|
95
|
-
message: payload.message,
|
|
96
|
-
declineInputPrompt: payload.declineInputPrompt ?? null,
|
|
97
|
-
enqueuedAt,
|
|
98
|
-
pid: null,
|
|
99
|
-
result: null,
|
|
100
|
-
error: null
|
|
101
|
-
},
|
|
102
|
-
pending: {
|
|
103
|
-
status: "pending-approval",
|
|
104
|
-
approvalId,
|
|
105
|
-
message: payload.message,
|
|
106
|
-
enqueuedAt
|
|
107
|
-
}
|
|
109
|
+
metadata,
|
|
110
|
+
pending
|
|
108
111
|
};
|
|
112
|
+
if (payload.plan !== undefined && payload.planHash !== undefined) {
|
|
113
|
+
approval.metadata.plan = payload.plan;
|
|
114
|
+
approval.metadata.planHash = payload.planHash;
|
|
115
|
+
approval.pending.planHash = payload.planHash;
|
|
116
|
+
}
|
|
117
|
+
return approval;
|
|
109
118
|
}
|
|
110
119
|
async function createApprovalTask(tasks, approval) {
|
|
111
120
|
await tasks.create({
|
|
@@ -198,6 +207,8 @@ function approvalPayloadFromTask(task) {
|
|
|
198
207
|
declineInputPrompt: typeof metadata.declineInputPrompt === "string" || metadata.declineInputPrompt === null
|
|
199
208
|
? metadata.declineInputPrompt
|
|
200
209
|
: undefined,
|
|
210
|
+
plan: isApprovalPlanValue(metadata.plan) ? metadata.plan : undefined,
|
|
211
|
+
planHash: typeof metadata.planHash === "string" ? metadata.planHash : undefined,
|
|
201
212
|
enqueuedAt: metadata.enqueuedAt,
|
|
202
213
|
pid: typeof metadata.pid === "number" || metadata.pid === null ? metadata.pid : undefined,
|
|
203
214
|
result: metadata.result,
|
|
@@ -12,6 +12,9 @@ export function validateHumanInLoopOnDefine(config) {
|
|
|
12
12
|
if (typeof config.humanInLoop.message !== "function") {
|
|
13
13
|
throw new Error(`${label} '${config.name}': humanInLoop.message must be a function`);
|
|
14
14
|
}
|
|
15
|
+
if (config.humanInLoop.plan !== undefined && typeof config.humanInLoop.plan !== "function") {
|
|
16
|
+
throw new Error(`${label} '${config.name}': humanInLoop.plan must be a function`);
|
|
17
|
+
}
|
|
15
18
|
}
|
|
16
19
|
export function mergeHumanInLoopFromGroup(groupHumanInLoop, childHumanInLoop) {
|
|
17
20
|
if (childHumanInLoop !== undefined) {
|
|
@@ -2,6 +2,7 @@ import { enqueueApproval, ensureApprovalList } from "./approval-tasks.js";
|
|
|
2
2
|
import { defaultProviderForPlatform } from "./default-provider.js";
|
|
3
3
|
import { spawnApprovalRunner } from "./spawn.js";
|
|
4
4
|
import { ApprovalDeclinedError } from "./types.js";
|
|
5
|
+
import { assertApprovalPlanHash, createApprovalPlan, formatApprovalMessage } from "./plan-hash.js";
|
|
5
6
|
const providersByRuntime = new WeakMap();
|
|
6
7
|
let providerWithoutRuntime;
|
|
7
8
|
export function resolveProvider(runtimeOptions) {
|
|
@@ -24,10 +25,17 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
|
|
|
24
25
|
if (!node.humanInLoop) {
|
|
25
26
|
return node.handler(ctx);
|
|
26
27
|
}
|
|
27
|
-
const
|
|
28
|
+
const planContext = {
|
|
28
29
|
params: ctx.params,
|
|
29
30
|
commandPath
|
|
30
|
-
}
|
|
31
|
+
};
|
|
32
|
+
const baseMessage = node.humanInLoop.message(planContext);
|
|
33
|
+
const approvalPlan = node.humanInLoop.plan === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: createApprovalPlan(await node.humanInLoop.plan(planContext));
|
|
36
|
+
const message = approvalPlan === undefined
|
|
37
|
+
? baseMessage
|
|
38
|
+
: formatApprovalMessage(baseMessage, approvalPlan);
|
|
31
39
|
if (node.humanInLoop.mode === "async") {
|
|
32
40
|
const { tasks } = await ensureApprovalList(runtimeOptions);
|
|
33
41
|
const { approvalId, pending } = await (options.enqueueApproval ?? enqueueApproval)({
|
|
@@ -36,6 +44,8 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
|
|
|
36
44
|
commandPath,
|
|
37
45
|
params: ctx.params,
|
|
38
46
|
message,
|
|
47
|
+
plan: approvalPlan?.value,
|
|
48
|
+
planHash: approvalPlan?.hash,
|
|
39
49
|
declineInputPrompt: node.humanInLoop.declineInputPrompt
|
|
40
50
|
}
|
|
41
51
|
});
|
|
@@ -55,5 +65,9 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
|
|
|
55
65
|
commandPath
|
|
56
66
|
});
|
|
57
67
|
}
|
|
68
|
+
if (approvalPlan !== undefined && node.humanInLoop.plan !== undefined) {
|
|
69
|
+
const executionPlan = createApprovalPlan(await node.humanInLoop.plan(planContext));
|
|
70
|
+
assertApprovalPlanHash(approvalPlan.hash, executionPlan.hash);
|
|
71
|
+
}
|
|
58
72
|
return node.handler(ctx);
|
|
59
73
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ApprovalPlanValue = null | boolean | number | string | ApprovalPlanValue[] | {
|
|
2
|
+
[key: string]: ApprovalPlanValue;
|
|
3
|
+
};
|
|
4
|
+
export interface ApprovalPlan {
|
|
5
|
+
value: ApprovalPlanValue;
|
|
6
|
+
canonical: string;
|
|
7
|
+
display: string;
|
|
8
|
+
hash: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function isApprovalPlanValue(value: unknown): value is ApprovalPlanValue;
|
|
11
|
+
export declare function createApprovalPlan(value: unknown): ApprovalPlan;
|
|
12
|
+
export declare function formatApprovalMessage(message: string, plan: ApprovalPlan): string;
|
|
13
|
+
export declare function assertApprovalPlanHash(expectedHash: string, actualHash: string): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { UserError } from "../user-error.js";
|
|
3
|
+
export function isApprovalPlanValue(value) {
|
|
4
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
if (typeof value === "number") {
|
|
8
|
+
return Number.isFinite(value);
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return value.every(isApprovalPlanValue);
|
|
12
|
+
}
|
|
13
|
+
if (typeof value !== "object") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const prototype = Object.getPrototypeOf(value);
|
|
17
|
+
return ((prototype === Object.prototype || prototype === null) &&
|
|
18
|
+
Object.values(value).every(isApprovalPlanValue));
|
|
19
|
+
}
|
|
20
|
+
function normalizePlan(value, seen) {
|
|
21
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "number") {
|
|
25
|
+
if (!Number.isFinite(value)) {
|
|
26
|
+
throw new UserError("Approval plan numbers must be finite.");
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value !== "object") {
|
|
31
|
+
throw new UserError("Approval plan must contain only JSON values.");
|
|
32
|
+
}
|
|
33
|
+
if (seen.has(value)) {
|
|
34
|
+
throw new UserError("Approval plan must not contain circular references.");
|
|
35
|
+
}
|
|
36
|
+
seen.add(value);
|
|
37
|
+
try {
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value.map((item) => normalizePlan(item, seen));
|
|
40
|
+
}
|
|
41
|
+
const prototype = Object.getPrototypeOf(value);
|
|
42
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
43
|
+
throw new UserError("Approval plan must contain only JSON values.");
|
|
44
|
+
}
|
|
45
|
+
const normalized = {};
|
|
46
|
+
for (const key of Object.keys(value).sort()) {
|
|
47
|
+
normalized[key] = normalizePlan(value[key], seen);
|
|
48
|
+
}
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
seen.delete(value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function createApprovalPlan(value) {
|
|
56
|
+
const normalized = normalizePlan(value, new Set());
|
|
57
|
+
const canonical = JSON.stringify(normalized);
|
|
58
|
+
const digest = createHash("sha256").update(canonical).digest("hex");
|
|
59
|
+
return {
|
|
60
|
+
value: normalized,
|
|
61
|
+
canonical,
|
|
62
|
+
display: JSON.stringify(normalized, null, 2),
|
|
63
|
+
hash: `sha256:${digest}`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function formatApprovalMessage(message, plan) {
|
|
67
|
+
return `${message}\n\nPlan:\n${plan.display}\n\nPlan hash: ${plan.hash}`;
|
|
68
|
+
}
|
|
69
|
+
export function assertApprovalPlanHash(expectedHash, actualHash) {
|
|
70
|
+
if (expectedHash !== actualHash) {
|
|
71
|
+
throw new UserError(`Approval plan changed after approval. Expected ${expectedHash}, received ${actualHash}.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -4,6 +4,7 @@ import { createEnv, createFs } from "../runtime/io.js";
|
|
|
4
4
|
import { ensureApprovalList } from "./approval-tasks.js";
|
|
5
5
|
import { resolveProvider } from "./gate.js";
|
|
6
6
|
import { createRuntimeLogger } from "../runtime-logging.js";
|
|
7
|
+
import { assertApprovalPlanHash, createApprovalPlan, formatApprovalMessage, isApprovalPlanValue } from "./plan-hash.js";
|
|
7
8
|
const MAX_AVAILABLE_COMMAND_PATHS = 20;
|
|
8
9
|
export async function runApproval(approvalId, runtimeOptions, root) {
|
|
9
10
|
const { tasks } = await ensureApprovalList(runtimeOptions);
|
|
@@ -27,6 +28,7 @@ export async function runApproval(approvalId, runtimeOptions, root) {
|
|
|
27
28
|
throw error;
|
|
28
29
|
}
|
|
29
30
|
try {
|
|
31
|
+
verifyStoredApprovalPlan(approval);
|
|
30
32
|
const approvalResult = await provider.requestApproval({
|
|
31
33
|
message: approval.message,
|
|
32
34
|
declineInputPrompt: approval.declineInputPrompt ?? undefined
|
|
@@ -50,10 +52,32 @@ export async function runApproval(approvalId, runtimeOptions, root) {
|
|
|
50
52
|
});
|
|
51
53
|
return;
|
|
52
54
|
}
|
|
55
|
+
let command;
|
|
56
|
+
let ctx;
|
|
57
|
+
try {
|
|
58
|
+
command = findCommand(root, approval.commandPath);
|
|
59
|
+
ctx = createHandlerContext(command, approval.params);
|
|
60
|
+
if (approval.planHash !== undefined) {
|
|
61
|
+
if (command.humanInLoop?.plan === undefined) {
|
|
62
|
+
throw new UserError("Approval plan can no longer be verified by this command.");
|
|
63
|
+
}
|
|
64
|
+
const executionPlan = createApprovalPlan(await command.humanInLoop.plan({
|
|
65
|
+
params: approval.params,
|
|
66
|
+
commandPath: approval.commandPath
|
|
67
|
+
}));
|
|
68
|
+
assertApprovalPlanHash(approval.planHash, executionPlan.hash);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
await tasks.fire(approvalId, "fail", {
|
|
73
|
+
metadataPatch: {
|
|
74
|
+
error: errorMetadataFromUnknown(error)
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
53
79
|
await tasks.fire(approvalId, "start");
|
|
54
80
|
try {
|
|
55
|
-
const command = findCommand(root, approval.commandPath);
|
|
56
|
-
const ctx = createHandlerContext(command, approval.params);
|
|
57
81
|
const result = await command.handler(ctx);
|
|
58
82
|
const serializedResult = serializeJsonResult(result);
|
|
59
83
|
if (!serializedResult.ok) {
|
|
@@ -80,6 +104,21 @@ export async function runApproval(approvalId, runtimeOptions, root) {
|
|
|
80
104
|
});
|
|
81
105
|
}
|
|
82
106
|
}
|
|
107
|
+
function verifyStoredApprovalPlan(approval) {
|
|
108
|
+
const hasPlan = approval.plan !== undefined;
|
|
109
|
+
const hasPlanHash = approval.planHash !== undefined;
|
|
110
|
+
if (!hasPlan && !hasPlanHash) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (approval.plan === undefined || approval.planHash === undefined) {
|
|
114
|
+
throw new UserError("Malformed approval plan metadata.");
|
|
115
|
+
}
|
|
116
|
+
const storedPlan = createApprovalPlan(approval.plan);
|
|
117
|
+
assertApprovalPlanHash(approval.planHash, storedPlan.hash);
|
|
118
|
+
if (!approval.message.endsWith(formatApprovalMessage("", storedPlan))) {
|
|
119
|
+
throw new UserError("Approval prompt does not match its stored plan hash.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
83
122
|
function readApprovalPayload(task) {
|
|
84
123
|
const metadata = task.metadata;
|
|
85
124
|
if (typeof metadata !== "object" || metadata === null) {
|
|
@@ -103,6 +142,8 @@ function readApprovalPayload(task) {
|
|
|
103
142
|
params: metadata.params,
|
|
104
143
|
message: metadata.message,
|
|
105
144
|
declineInputPrompt,
|
|
145
|
+
plan: isApprovalPlanValue(metadata.plan) ? metadata.plan : undefined,
|
|
146
|
+
planHash: typeof metadata.planHash === "string" ? metadata.planHash : undefined,
|
|
106
147
|
enqueuedAt: typeof metadata.enqueuedAt === "string" ? metadata.enqueuedAt : undefined,
|
|
107
148
|
pid: typeof metadata.pid === "number" || metadata.pid === null ? metadata.pid : undefined,
|
|
108
149
|
result: metadata.result,
|
|
@@ -8,6 +8,10 @@ export interface HumanInLoopConfig<TParamsSchema extends ObjectSchema<any>> {
|
|
|
8
8
|
params: Static<TParamsSchema>;
|
|
9
9
|
commandPath: string;
|
|
10
10
|
}) => string;
|
|
11
|
+
plan?: (ctx: {
|
|
12
|
+
params: Static<TParamsSchema>;
|
|
13
|
+
commandPath: string;
|
|
14
|
+
}) => unknown | Promise<unknown>;
|
|
11
15
|
declineInputPrompt?: string;
|
|
12
16
|
}
|
|
13
17
|
export interface HumanInLoopRuntimeOptions {
|
|
@@ -27,6 +31,7 @@ export interface HumanInLoopPending {
|
|
|
27
31
|
approvalId: string;
|
|
28
32
|
message: string;
|
|
29
33
|
enqueuedAt: string;
|
|
34
|
+
planHash?: string;
|
|
30
35
|
}
|
|
31
36
|
export declare class ApprovalDeclinedError extends UserError {
|
|
32
37
|
readonly reason?: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { S } from "toolcraft-schema";
|
|
2
2
|
import { defineCommand, defineGroup } from "./index.js";
|
|
3
|
+
import { createFileChangeRenderers } from "./file-change-renderer.js";
|
|
3
4
|
const ignoredScope = ["cli", "sdk"];
|
|
4
5
|
const ignoredSecrets = {
|
|
5
6
|
apiKey: { env: "API_KEY" },
|
|
@@ -29,6 +30,14 @@ const ignoredCommand = defineCommand({
|
|
|
29
30
|
};
|
|
30
31
|
},
|
|
31
32
|
});
|
|
33
|
+
const ignoredFileChangeCommand = defineCommand({
|
|
34
|
+
name: "status",
|
|
35
|
+
params: S.Object({}),
|
|
36
|
+
render: createFileChangeRenderers({ mode: "status" }),
|
|
37
|
+
handler: async () => ({
|
|
38
|
+
changes: [{ kind: "added", path: "flows/morning.json", newContent: "{}\n" }]
|
|
39
|
+
})
|
|
40
|
+
});
|
|
32
41
|
const ignoredGroup = defineGroup({
|
|
33
42
|
name: "root",
|
|
34
43
|
scope: ignoredScope,
|
package/dist/index.d.ts
CHANGED
|
@@ -208,6 +208,8 @@ export { ApprovalDeclinedError, ToolcraftBugError, UserError };
|
|
|
208
208
|
export { createRuntimeLogger, isLogLevel, shouldEmitDiagnostic } from "./runtime-logging.js";
|
|
209
209
|
export { findPackageMetadata, packageMetadata } from "./package-metadata.js";
|
|
210
210
|
export type { PackageMetadata } from "./package-metadata.js";
|
|
211
|
+
export type { FileChangeRendererOptions, FileChangeResult } from "./file-change-renderer.js";
|
|
212
|
+
export type { FileChange, FileChangeDisplayMode, FileChangeKind } from "toolcraft-design";
|
|
211
213
|
export type { DiagnosticLogEvent, LogLevel, RuntimeLogger, RuntimeLoggerInput } from "./runtime-logging.js";
|
|
212
214
|
export type { AnySchema, ArraySchema, BooleanSchema, EnumSchema, JsonSchema, NumberSchema, ObjectSchema, OptionalSchema, Static, StringSchema } from "toolcraft-schema";
|
|
213
215
|
export type { HumanInLoopConfig, HumanInLoopPending, HumanInLoopRuntimeOptions };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type FileChangeKind = "added" | "modified" | "deleted" | "renamed";
|
|
2
|
+
export type FileChangeDisplayMode = "status" | "diff";
|
|
3
|
+
export type FileChangeOutputFormat = "terminal" | "markdown";
|
|
4
|
+
export interface FileChange {
|
|
5
|
+
path: string;
|
|
6
|
+
kind: FileChangeKind;
|
|
7
|
+
oldPath?: string;
|
|
8
|
+
conflict?: boolean;
|
|
9
|
+
oldContent?: string;
|
|
10
|
+
newContent?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface RenderFileChangesOptions {
|
|
13
|
+
mode?: FileChangeDisplayMode;
|
|
14
|
+
format?: FileChangeOutputFormat;
|
|
15
|
+
}
|
|
16
|
+
export declare function renderFileChanges(changes: readonly FileChange[], options?: RenderFileChangesOptions): string;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { color } from "./color.js";
|
|
2
|
+
const STATUS_MARKERS = {
|
|
3
|
+
added: "A",
|
|
4
|
+
modified: "M",
|
|
5
|
+
deleted: "D",
|
|
6
|
+
renamed: "R"
|
|
7
|
+
};
|
|
8
|
+
const STATUS_LABELS = {
|
|
9
|
+
added: "added",
|
|
10
|
+
modified: "modified",
|
|
11
|
+
deleted: "deleted",
|
|
12
|
+
renamed: "renamed"
|
|
13
|
+
};
|
|
14
|
+
function changePath(change) {
|
|
15
|
+
if (change.kind === "renamed" && change.oldPath) {
|
|
16
|
+
return `${change.oldPath} -> ${change.path}`;
|
|
17
|
+
}
|
|
18
|
+
return change.path;
|
|
19
|
+
}
|
|
20
|
+
function colorStatusMarker(change, marker) {
|
|
21
|
+
if (change.conflict) {
|
|
22
|
+
return color.red.bold(marker);
|
|
23
|
+
}
|
|
24
|
+
switch (change.kind) {
|
|
25
|
+
case "added":
|
|
26
|
+
return color.green(marker);
|
|
27
|
+
case "modified":
|
|
28
|
+
return color.yellow(marker);
|
|
29
|
+
case "deleted":
|
|
30
|
+
return color.red(marker);
|
|
31
|
+
case "renamed":
|
|
32
|
+
return color.cyan(marker);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function formatSummary(changes) {
|
|
36
|
+
const counts = new Map();
|
|
37
|
+
let conflicts = 0;
|
|
38
|
+
for (const change of changes) {
|
|
39
|
+
counts.set(change.kind, (counts.get(change.kind) ?? 0) + 1);
|
|
40
|
+
if (change.conflict) {
|
|
41
|
+
conflicts += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const details = Object.keys(STATUS_LABELS)
|
|
45
|
+
.flatMap((kind) => {
|
|
46
|
+
const count = counts.get(kind) ?? 0;
|
|
47
|
+
return count > 0 ? [`${count} ${STATUS_LABELS[kind]}`] : [];
|
|
48
|
+
});
|
|
49
|
+
if (conflicts > 0) {
|
|
50
|
+
details.push(`${conflicts} ${conflicts === 1 ? "conflict" : "conflicts"}`);
|
|
51
|
+
}
|
|
52
|
+
return `${changes.length} ${changes.length === 1 ? "change" : "changes"} (${details.join(", ")})`;
|
|
53
|
+
}
|
|
54
|
+
function renderStatus(changes, format) {
|
|
55
|
+
const lines = changes.map((change) => {
|
|
56
|
+
const marker = `${STATUS_MARKERS[change.kind]}${change.conflict ? "!" : " "}`;
|
|
57
|
+
const renderedMarker = format === "terminal" ? colorStatusMarker(change, marker) : marker;
|
|
58
|
+
return `${renderedMarker} ${changePath(change)}`;
|
|
59
|
+
});
|
|
60
|
+
const output = [...lines, "", formatSummary(changes)].join("\n");
|
|
61
|
+
return format === "markdown" ? `\`\`\`text\n${output}\n\`\`\`` : output;
|
|
62
|
+
}
|
|
63
|
+
function diffPath(prefix, path) {
|
|
64
|
+
return `${prefix}/${path}`;
|
|
65
|
+
}
|
|
66
|
+
function contentLines(content) {
|
|
67
|
+
const lines = content.split("\n");
|
|
68
|
+
if (lines.at(-1) === "") {
|
|
69
|
+
lines.pop();
|
|
70
|
+
}
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
function commonPrefixLength(oldLines, newLines) {
|
|
74
|
+
const limit = Math.min(oldLines.length, newLines.length);
|
|
75
|
+
let index = 0;
|
|
76
|
+
while (index < limit && oldLines[index] === newLines[index]) {
|
|
77
|
+
index += 1;
|
|
78
|
+
}
|
|
79
|
+
return index;
|
|
80
|
+
}
|
|
81
|
+
function commonSuffixLength(oldLines, newLines, prefixLength) {
|
|
82
|
+
const limit = Math.min(oldLines.length, newLines.length) - prefixLength;
|
|
83
|
+
let length = 0;
|
|
84
|
+
while (length < limit &&
|
|
85
|
+
oldLines[oldLines.length - length - 1] === newLines[newLines.length - length - 1]) {
|
|
86
|
+
length += 1;
|
|
87
|
+
}
|
|
88
|
+
return length;
|
|
89
|
+
}
|
|
90
|
+
function hunkRange(startIndex, count) {
|
|
91
|
+
const lineNumber = count === 0 ? 0 : startIndex + 1;
|
|
92
|
+
return `${lineNumber},${count}`;
|
|
93
|
+
}
|
|
94
|
+
function createUnifiedHunk(oldContent, newContent) {
|
|
95
|
+
const oldLines = contentLines(oldContent);
|
|
96
|
+
const newLines = contentLines(newContent);
|
|
97
|
+
const prefixLength = commonPrefixLength(oldLines, newLines);
|
|
98
|
+
const suffixLength = commonSuffixLength(oldLines, newLines, prefixLength);
|
|
99
|
+
const context = 3;
|
|
100
|
+
const oldChangeEnd = oldLines.length - suffixLength;
|
|
101
|
+
const newChangeEnd = newLines.length - suffixLength;
|
|
102
|
+
const oldStart = Math.max(0, prefixLength - context);
|
|
103
|
+
const newStart = Math.max(0, prefixLength - context);
|
|
104
|
+
const oldEnd = Math.min(oldLines.length, oldChangeEnd + context);
|
|
105
|
+
const newEnd = Math.min(newLines.length, newChangeEnd + context);
|
|
106
|
+
const oldCount = oldEnd - oldStart;
|
|
107
|
+
const newCount = newEnd - newStart;
|
|
108
|
+
const before = oldLines.slice(oldStart, prefixLength).map((line) => ` ${line}`);
|
|
109
|
+
const removed = oldLines.slice(prefixLength, oldChangeEnd).map((line) => `-${line}`);
|
|
110
|
+
const added = newLines.slice(prefixLength, newChangeEnd).map((line) => `+${line}`);
|
|
111
|
+
const after = oldLines.slice(oldChangeEnd, oldEnd).map((line) => ` ${line}`);
|
|
112
|
+
return [
|
|
113
|
+
`@@ -${hunkRange(oldStart, oldCount)} +${hunkRange(newStart, newCount)} @@`,
|
|
114
|
+
...before,
|
|
115
|
+
...removed,
|
|
116
|
+
...added,
|
|
117
|
+
...after
|
|
118
|
+
].join("\n");
|
|
119
|
+
}
|
|
120
|
+
function createChangePatch(change) {
|
|
121
|
+
const oldPath = change.kind === "added"
|
|
122
|
+
? "/dev/null"
|
|
123
|
+
: diffPath("a", change.oldPath ?? change.path);
|
|
124
|
+
const newPath = change.kind === "deleted" ? "/dev/null" : diffPath("b", change.path);
|
|
125
|
+
const oldContent = change.kind === "added" ? "" : (change.oldContent ?? "");
|
|
126
|
+
const newContent = change.kind === "deleted" ? "" : (change.newContent ?? "");
|
|
127
|
+
const headers = [`--- ${oldPath}`, `+++ ${newPath}`];
|
|
128
|
+
if (oldContent !== newContent) {
|
|
129
|
+
return [...headers, createUnifiedHunk(oldContent, newContent)].join("\n");
|
|
130
|
+
}
|
|
131
|
+
return headers.join("\n");
|
|
132
|
+
}
|
|
133
|
+
function colorDiffLine(line) {
|
|
134
|
+
if (line.startsWith("@@")) {
|
|
135
|
+
return color.cyan(line);
|
|
136
|
+
}
|
|
137
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
138
|
+
return color.bold(line);
|
|
139
|
+
}
|
|
140
|
+
if (line.startsWith("+")) {
|
|
141
|
+
return color.green(line);
|
|
142
|
+
}
|
|
143
|
+
if (line.startsWith("-")) {
|
|
144
|
+
return color.red(line);
|
|
145
|
+
}
|
|
146
|
+
return line;
|
|
147
|
+
}
|
|
148
|
+
function renderDiff(changes, format) {
|
|
149
|
+
const output = changes.map(createChangePatch).join("\n\n");
|
|
150
|
+
if (format === "markdown") {
|
|
151
|
+
return `\`\`\`diff\n${output}\n\`\`\``;
|
|
152
|
+
}
|
|
153
|
+
return output.split("\n").map(colorDiffLine).join("\n");
|
|
154
|
+
}
|
|
155
|
+
export function renderFileChanges(changes, options = {}) {
|
|
156
|
+
if (changes.length === 0) {
|
|
157
|
+
return "No file changes.";
|
|
158
|
+
}
|
|
159
|
+
const mode = options.mode ?? "status";
|
|
160
|
+
const format = options.format ?? "terminal";
|
|
161
|
+
return mode === "diff" ? renderDiff(changes, format) : renderStatus(changes, format);
|
|
162
|
+
}
|
|
@@ -10,6 +10,8 @@ export { formatCommandNotFound } from "./command-errors.js";
|
|
|
10
10
|
export { formatCommandNotFoundPanel } from "./command-errors.js";
|
|
11
11
|
export { renderTable } from "./table.js";
|
|
12
12
|
export type { TableColumn, RenderTableOptions } from "./table.js";
|
|
13
|
+
export { renderFileChanges } from "./file-changes.js";
|
|
14
|
+
export type { FileChange, FileChangeDisplayMode, FileChangeKind, FileChangeOutputFormat, RenderFileChangesOptions } from "./file-changes.js";
|
|
13
15
|
export { renderCatalog } from "./catalog.js";
|
|
14
16
|
export type { CatalogGroup, CatalogItem, CatalogMetric, CatalogTone, RenderCatalogOptions } from "./catalog.js";
|
|
15
17
|
export { getTemplatePartialNames, renderTemplate, resolveTemplatePartials } from "./template.js";
|
|
@@ -6,5 +6,6 @@ export { helpFormatter, formatColumns, formatCommand, formatUsage, formatOption,
|
|
|
6
6
|
export { formatCommandNotFound } from "./command-errors.js";
|
|
7
7
|
export { formatCommandNotFoundPanel } from "./command-errors.js";
|
|
8
8
|
export { renderTable } from "./table.js";
|
|
9
|
+
export { renderFileChanges } from "./file-changes.js";
|
|
9
10
|
export { renderCatalog } from "./catalog.js";
|
|
10
11
|
export { getTemplatePartialNames, renderTemplate, resolveTemplatePartials } from "./template.js";
|
|
@@ -19,6 +19,8 @@ export { formatCommandNotFound } from "./components/command-errors.js";
|
|
|
19
19
|
export { formatCommandNotFoundPanel } from "./components/command-errors.js";
|
|
20
20
|
export { renderTable } from "./components/table.js";
|
|
21
21
|
export type { TableColumn, RenderTableOptions } from "./components/table.js";
|
|
22
|
+
export { renderFileChanges } from "./components/file-changes.js";
|
|
23
|
+
export type { FileChange, FileChangeDisplayMode, FileChangeKind, FileChangeOutputFormat, RenderFileChangesOptions } from "./components/file-changes.js";
|
|
22
24
|
export { renderCatalog } from "./components/catalog.js";
|
|
23
25
|
export type { CatalogGroup, CatalogItem, CatalogMetric, CatalogTone, RenderCatalogOptions } from "./components/catalog.js";
|
|
24
26
|
export { renderDetailCard } from "./components/detail-card.js";
|
|
@@ -15,6 +15,7 @@ export * as helpFormatterPlain from "./components/help-formatter-plain.js";
|
|
|
15
15
|
export { formatCommandNotFound } from "./components/command-errors.js";
|
|
16
16
|
export { formatCommandNotFoundPanel } from "./components/command-errors.js";
|
|
17
17
|
export { renderTable } from "./components/table.js";
|
|
18
|
+
export { renderFileChanges } from "./components/file-changes.js";
|
|
18
19
|
export { renderCatalog } from "./components/catalog.js";
|
|
19
20
|
export { renderDetailCard } from "./components/detail-card.js";
|
|
20
21
|
export { renderInspectorCard } from "./components/inspector-card.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolcraft",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.100",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -70,6 +70,10 @@
|
|
|
70
70
|
"types": "./dist/frontmatter.d.ts",
|
|
71
71
|
"import": "./dist/frontmatter.js"
|
|
72
72
|
},
|
|
73
|
+
"./file-changes": {
|
|
74
|
+
"types": "./dist/file-change-renderer.d.ts",
|
|
75
|
+
"import": "./dist/file-change-renderer.js"
|
|
76
|
+
},
|
|
73
77
|
"./process-runner": {
|
|
74
78
|
"types": "./dist/process-runner.d.ts",
|
|
75
79
|
"import": "./dist/process-runner.js"
|
|
@@ -100,7 +104,7 @@
|
|
|
100
104
|
"postpack": "node ../../scripts/manage-bundled-workspace-deps.mjs cleanup . toolcraft-design @poe-code/frontmatter @poe-code/agent-mcp-config @poe-code/agent-human-in-loop @poe-code/task-list @poe-code/agent-defs @poe-code/config-mutations @poe-code/process-runner tiny-mcp-client auth-store"
|
|
101
105
|
},
|
|
102
106
|
"dependencies": {
|
|
103
|
-
"toolcraft-schema": "0.0.
|
|
107
|
+
"toolcraft-schema": "0.0.100",
|
|
104
108
|
"commander": "^13.1.0",
|
|
105
109
|
"fast-string-width": "^3.0.2",
|
|
106
110
|
"fast-wrap-ansi": "^0.2.0",
|