claude-code-rust 0.12.2 → 0.12.3
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
CHANGED
|
@@ -38,12 +38,8 @@ claude-rs
|
|
|
38
38
|
|
|
39
39
|
Full documentation is available at [srothgan.github.io/claude-code-rust](https://srothgan.github.io/claude-code-rust/).
|
|
40
40
|
|
|
41
|
-
> [!
|
|
42
|
-
> **Agent SDK billing
|
|
43
|
-
>
|
|
44
|
-
> Sources:
|
|
45
|
-
> - [Anthropic support: Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)
|
|
46
|
-
> - [ClaudeDevs announcement](https://x.com/ClaudeDevs/status/2054610152817619388)
|
|
41
|
+
> [!NOTE]
|
|
42
|
+
> **Agent SDK billing unchanged.** Anthropic has paused the previously announced Agent SDK credit change. For now nothing changes: Claude Agent SDK usage — including `claude -p` and third-party apps like this one — still draws from your normal Claude subscription limits. See [Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan).
|
|
47
43
|
|
|
48
44
|
## Why
|
|
49
45
|
|
|
@@ -85,7 +81,7 @@ This project is not affiliated with, endorsed by, or supported by Anthropic.
|
|
|
85
81
|
|
|
86
82
|
A quick note on where this project stands, since I know people worry about this kind of thing: claude-code-rust is a terminal UI that I wrote from scratch in Rust. It is not a fork, copy or port of the latest Claude Code source leak -- it talks to Anthropic's official [Agent SDK](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/agent-sdk) as a runtime dependency instead, the same way any other third-party tool would. No Anthropic source code was read or used as reference at any point during development.
|
|
87
83
|
|
|
88
|
-
The project authenticates through your existing Claude Code account via the Agent SDK, and the Agent SDK's terms allow building on top of it. Billing, credits, limits, and overage behavior are controlled by Anthropic, including
|
|
84
|
+
The project authenticates through your existing Claude Code account via the Agent SDK, and the Agent SDK's terms allow building on top of it. Billing, credits, limits, and overage behavior are controlled by Anthropic, including any future changes Anthropic may make to how Agent SDK usage is metered. Other community projects do the same. As far as I can tell, using this project is fine -- but I am a single maintainer, not a lawyer. If anything changes on Anthropic's end, I will update this section and adjust the project accordingly.
|
|
89
85
|
|
|
90
86
|
This project's source code is licensed under [Apache-2.0](LICENSE). The Agent SDK itself is proprietary and governed by [Anthropic's Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms).
|
|
91
87
|
|
|
@@ -50,6 +50,9 @@ function inputBoolean(input, key) {
|
|
|
50
50
|
function isAgentLikeToolName(name) {
|
|
51
51
|
return name === "Agent" || name === "Task";
|
|
52
52
|
}
|
|
53
|
+
export function isShellToolName(name) {
|
|
54
|
+
return name === "Bash" || name === "PowerShell";
|
|
55
|
+
}
|
|
53
56
|
function agentInputTitle(name, input) {
|
|
54
57
|
if (!isAgentLikeToolName(name)) {
|
|
55
58
|
return undefined;
|
|
@@ -135,9 +138,10 @@ function formatGrepTitle(input) {
|
|
|
135
138
|
return flags.length > 0 ? `${scoped} (${flags.join(", ")})` : scoped;
|
|
136
139
|
}
|
|
137
140
|
export function normalizeToolKind(name) {
|
|
141
|
+
if (isShellToolName(name)) {
|
|
142
|
+
return "execute";
|
|
143
|
+
}
|
|
138
144
|
switch (name) {
|
|
139
|
-
case "Bash":
|
|
140
|
-
return "execute";
|
|
141
145
|
case "Read":
|
|
142
146
|
case "ReadMcpResource":
|
|
143
147
|
return "read";
|
|
@@ -189,7 +193,7 @@ export function toolTitle(name, input, context = {}) {
|
|
|
189
193
|
if (agentTitle) {
|
|
190
194
|
return agentTitle;
|
|
191
195
|
}
|
|
192
|
-
if (name
|
|
196
|
+
if (isShellToolName(name)) {
|
|
193
197
|
const command = typeof input.command === "string" ? input.command : "";
|
|
194
198
|
return command || "Terminal";
|
|
195
199
|
}
|
|
@@ -684,14 +688,14 @@ function editDiffFromResult(rawResult, rawInput) {
|
|
|
684
688
|
}
|
|
685
689
|
return editDiffFromInput(rawInput);
|
|
686
690
|
}
|
|
687
|
-
function
|
|
691
|
+
function findShellResultRecord(rawResult, rawContent) {
|
|
688
692
|
return resultRecordCandidates(rawResult, rawContent).find((candidate) => "stdout" in candidate ||
|
|
689
693
|
"stderr" in candidate ||
|
|
690
694
|
"backgroundTaskId" in candidate ||
|
|
691
695
|
"backgroundedByUser" in candidate ||
|
|
692
696
|
"assistantAutoBackgrounded" in candidate);
|
|
693
697
|
}
|
|
694
|
-
function
|
|
698
|
+
function shellBackgroundMessage(record) {
|
|
695
699
|
const backgroundTaskId = typeof record.backgroundTaskId === "string" ? record.backgroundTaskId : "";
|
|
696
700
|
if (!backgroundTaskId) {
|
|
697
701
|
return "";
|
|
@@ -704,7 +708,7 @@ function bashBackgroundMessage(record) {
|
|
|
704
708
|
}
|
|
705
709
|
return `Command is running in background with ID: ${backgroundTaskId}.`;
|
|
706
710
|
}
|
|
707
|
-
function
|
|
711
|
+
function buildShellDisplayOutput(record) {
|
|
708
712
|
const segments = [];
|
|
709
713
|
const stdout = typeof record.stdout === "string" ? record.stdout : "";
|
|
710
714
|
const stderr = typeof record.stderr === "string" ? record.stderr : "";
|
|
@@ -717,7 +721,7 @@ function buildBashDisplayOutput(record) {
|
|
|
717
721
|
if (record.interrupted === true) {
|
|
718
722
|
segments.push("Command was aborted before completion.");
|
|
719
723
|
}
|
|
720
|
-
const backgroundMessage =
|
|
724
|
+
const backgroundMessage = shellBackgroundMessage(record);
|
|
721
725
|
if (backgroundMessage) {
|
|
722
726
|
segments.push(backgroundMessage);
|
|
723
727
|
}
|
|
@@ -1777,10 +1781,12 @@ export function buildToolResultFields(isError, rawContent, base, rawResult, _con
|
|
|
1777
1781
|
}
|
|
1778
1782
|
return fields;
|
|
1779
1783
|
}
|
|
1780
|
-
const
|
|
1784
|
+
const shellResultRecord = isShellToolName(toolName)
|
|
1785
|
+
? findShellResultRecord(rawResult, rawContent)
|
|
1786
|
+
: undefined;
|
|
1781
1787
|
const normalizedRawOutput = normalizeToolResultText(rawContent, isError);
|
|
1782
|
-
const rawOutput =
|
|
1783
|
-
?
|
|
1788
|
+
const rawOutput = shellResultRecord
|
|
1789
|
+
? buildShellDisplayOutput(shellResultRecord)
|
|
1784
1790
|
: normalizedRawOutput || JSON.stringify(rawContent);
|
|
1785
1791
|
if (rawOutput && !(isTaskToolName(toolName) && !isError)) {
|
|
1786
1792
|
fields.raw_output = rawOutput;
|
package/agent-sdk/dist/bridge.js
CHANGED
|
@@ -18,7 +18,7 @@ import { bridgeLogger, LOG_TARGETS, logBridgeCommandReceived } from "./bridge/lo
|
|
|
18
18
|
export { AsyncQueue } from "./bridge/shared.js";
|
|
19
19
|
export { asRecordOrNull } from "./bridge/shared.js";
|
|
20
20
|
export { CACHE_SPLIT_POLICY, previewKilobyteLabel } from "./bridge/cache_policy.js";
|
|
21
|
-
export { buildToolResultFields, createToolCall, normalizeToolKind, normalizeToolResultText, unwrapToolUseResult, } from "./bridge/tooling.js";
|
|
21
|
+
export { buildToolResultFields, createToolCall, isShellToolName, normalizeToolKind, normalizeToolResultText, unwrapToolUseResult, } from "./bridge/tooling.js";
|
|
22
22
|
export { looksLikeAuthRequired } from "./bridge/auth.js";
|
|
23
23
|
export { parseCommandEnvelope } from "./bridge/commands.js";
|
|
24
24
|
export { buildSessionListOptions } from "./bridge/events.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, applySessionAgent, applySessionEffort, emitAgentConfigOptionUpdate, emitEffortConfigOptionUpdate, handleTaskSystemMessage, handleSdkMessage, mapSdkAccountInfo, mapAvailableAgents, mapAvailableModels, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRuntimeSessionState, parseRateLimitStatus, bridgeMcpConfigToSdk, mapMcpServerStatus, mapMcpServerStatusConfig, normalizeSettingsParseError, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, staleMcpAuthCandidates, resolveInstalledAgentSdkVersion, unwrapToolUseResult, updateAvailableCommands, handleReloadPluginsCommand, } from "./bridge.js";
|
|
3
|
+
import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, applySessionAgent, applySessionEffort, emitAgentConfigOptionUpdate, emitEffortConfigOptionUpdate, handleTaskSystemMessage, handleSdkMessage, isShellToolName, mapSdkAccountInfo, mapAvailableAgents, mapAvailableModels, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRuntimeSessionState, parseRateLimitStatus, bridgeMcpConfigToSdk, mapMcpServerStatus, mapMcpServerStatusConfig, normalizeSettingsParseError, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, staleMcpAuthCandidates, resolveInstalledAgentSdkVersion, unwrapToolUseResult, updateAvailableCommands, handleReloadPluginsCommand, } from "./bridge.js";
|
|
4
4
|
import { availableModesForSession, buildModeState, markModeUnavailableForSession, permissionModeFailureLooksUnsupported, refreshSupportedModesForSession, } from "./bridge/commands.js";
|
|
5
5
|
import { handleMcpSetServersCommand } from "./bridge/mcp.js";
|
|
6
6
|
import { emitCurrentModelUpdate, handleUserDialogResponse, refreshCurrentModel, resolveCurrentModel, sessions, shouldInvalidateResolvedRuntimeModel, shouldEmitStartupAuthRequiredForAccount, } from "./bridge/session_lifecycle.js";
|
|
@@ -2889,6 +2889,7 @@ test("requestAskUserQuestionAnswers preserves previews and annotations in update
|
|
|
2889
2889
|
});
|
|
2890
2890
|
test("normalizeToolKind maps known tool names", () => {
|
|
2891
2891
|
assert.equal(normalizeToolKind("Bash"), "execute");
|
|
2892
|
+
assert.equal(normalizeToolKind("PowerShell"), "execute");
|
|
2892
2893
|
assert.equal(normalizeToolKind("Delete"), "delete");
|
|
2893
2894
|
assert.equal(normalizeToolKind("Move"), "move");
|
|
2894
2895
|
assert.equal(normalizeToolKind("EnterWorktree"), "other");
|
|
@@ -2913,6 +2914,17 @@ test("normalizeToolKind maps known tool names", () => {
|
|
|
2913
2914
|
assert.equal(normalizeToolKind("ExitPlanMode"), "switch_mode");
|
|
2914
2915
|
assert.equal(normalizeToolKind("TodoWrite"), normalizeToolKind("FutureUnknownTool"));
|
|
2915
2916
|
});
|
|
2917
|
+
test("isShellToolName recognizes only supported shell tools", () => {
|
|
2918
|
+
assert.equal(isShellToolName("Bash"), true);
|
|
2919
|
+
assert.equal(isShellToolName("PowerShell"), true);
|
|
2920
|
+
assert.equal(isShellToolName("Shell"), false);
|
|
2921
|
+
assert.equal(isShellToolName("bash"), false);
|
|
2922
|
+
});
|
|
2923
|
+
test("shell tool titles use input command", () => {
|
|
2924
|
+
assert.equal(createToolCall("tc-bash-title", "Bash", { command: "git status" }).title, "git status");
|
|
2925
|
+
assert.equal(createToolCall("tc-powershell-title", "PowerShell", { command: "Get-ChildItem" }).title, "Get-ChildItem");
|
|
2926
|
+
assert.equal(createToolCall("tc-powershell-empty", "PowerShell", {}).title, "Terminal");
|
|
2927
|
+
});
|
|
2916
2928
|
test("parseFastModeState accepts known values and rejects unknown values", () => {
|
|
2917
2929
|
assert.equal(parseFastModeState("off"), "off");
|
|
2918
2930
|
assert.equal(parseFastModeState("cooldown"), "cooldown");
|
|
@@ -4048,6 +4060,22 @@ test("buildToolResultFields ignores model-facing Bash stale read hints", () => {
|
|
|
4048
4060
|
assert.equal(fields.raw_output, "real stdout");
|
|
4049
4061
|
assert.equal(fields.output_metadata, undefined);
|
|
4050
4062
|
});
|
|
4063
|
+
test("buildToolResultFields maps PowerShell structured output like shell output", () => {
|
|
4064
|
+
const base = createToolCall("tc-powershell", "PowerShell", { command: "Get-ChildItem" });
|
|
4065
|
+
const fields = buildToolResultFields(false, {
|
|
4066
|
+
stdout: "stdout line",
|
|
4067
|
+
stderr: "stderr line",
|
|
4068
|
+
interrupted: true,
|
|
4069
|
+
}, base, {
|
|
4070
|
+
result: {
|
|
4071
|
+
stdout: "stdout line",
|
|
4072
|
+
stderr: "stderr line",
|
|
4073
|
+
interrupted: true,
|
|
4074
|
+
},
|
|
4075
|
+
});
|
|
4076
|
+
assert.equal(fields.raw_output, "stdout line\nstderr line\nCommand was aborted before completion.");
|
|
4077
|
+
assert.equal(fields.output_metadata, undefined);
|
|
4078
|
+
});
|
|
4051
4079
|
test("buildToolResultFields adds Bash auto-backgrounded metadata and message", () => {
|
|
4052
4080
|
const base = createToolCall("tc-bash-bg", "Bash", { command: "npm run watch" });
|
|
4053
4081
|
const fields = buildToolResultFields(false, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-rust",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"description": "Claude Code Rust - native Rust terminal interface for Claude Code",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -36,7 +36,12 @@
|
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"postinstall": "node ./scripts/postinstall.js",
|
|
39
|
-
"prepack": "npm --prefix agent-sdk run build"
|
|
39
|
+
"prepack": "npm --prefix agent-sdk run build",
|
|
40
|
+
"quality:duplicates": "jscpd --config .jscpd.json src agent-sdk/src scripts bin --no-tips --no-colors",
|
|
41
|
+
"quality:duplicates:summary": "node scripts/jscpd-warning-summary.mjs jscpd-report/jscpd-report.json"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"jscpd": "5.0.9"
|
|
40
45
|
},
|
|
41
46
|
"engines": {
|
|
42
47
|
"node": ">=18"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
const reportPath = process.argv[2] ?? "jscpd-report/jscpd-report.json";
|
|
5
|
+
const warningThreshold = Number.parseFloat(process.env.JSCPD_WARNING_THRESHOLD ?? "3.0");
|
|
6
|
+
|
|
7
|
+
function escapeWorkflowCommand(value) {
|
|
8
|
+
return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function markdownCell(value) {
|
|
12
|
+
return String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function asNumber(value, fallback = 0) {
|
|
16
|
+
const number = Number(value);
|
|
17
|
+
return Number.isFinite(number) ? number : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatPercent(value) {
|
|
21
|
+
return `${asNumber(value).toFixed(2)}%`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatLocation(location) {
|
|
25
|
+
if (!location?.name) {
|
|
26
|
+
return "unknown";
|
|
27
|
+
}
|
|
28
|
+
const name = String(location.name).replaceAll("\\", "/");
|
|
29
|
+
const start = asNumber(location.start, asNumber(location.startLoc?.line, 1));
|
|
30
|
+
const end = asNumber(location.end, asNumber(location.endLoc?.line, start));
|
|
31
|
+
return `${name}:${start}-${end}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function appendSummary(markdown) {
|
|
35
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
36
|
+
if (summaryPath) {
|
|
37
|
+
fs.appendFileSync(summaryPath, `${markdown}\n`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function emitWarning(message) {
|
|
42
|
+
console.log(`::warning title=Duplicate code soft threshold::${escapeWorkflowCommand(message)}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!fs.existsSync(reportPath)) {
|
|
46
|
+
const message = `jscpd report not found at ${reportPath}; duplicate-code summary was skipped.`;
|
|
47
|
+
emitWarning(message);
|
|
48
|
+
appendSummary(`### Duplicate Code Scan\n\n${message}\n`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
|
53
|
+
const total = report.statistics?.total ?? {};
|
|
54
|
+
const formats = report.statistics?.formats ?? {};
|
|
55
|
+
const duplicates = Array.isArray(report.duplicates) ? report.duplicates : [];
|
|
56
|
+
|
|
57
|
+
const percentage = asNumber(total.percentage);
|
|
58
|
+
const clones = asNumber(total.clones, duplicates.length);
|
|
59
|
+
const duplicatedLines = asNumber(total.duplicatedLines);
|
|
60
|
+
const totalLines = asNumber(total.lines);
|
|
61
|
+
const duplicatedTokens = asNumber(total.duplicatedTokens);
|
|
62
|
+
const totalTokens = asNumber(total.tokens);
|
|
63
|
+
|
|
64
|
+
const status =
|
|
65
|
+
percentage >= warningThreshold
|
|
66
|
+
? `Warning: duplicated lines are ${formatPercent(percentage)}, above the ${formatPercent(warningThreshold)} soft threshold.`
|
|
67
|
+
: `OK: duplicated lines are ${formatPercent(percentage)}, below the ${formatPercent(warningThreshold)} soft threshold.`;
|
|
68
|
+
|
|
69
|
+
console.log(status);
|
|
70
|
+
console.log(
|
|
71
|
+
`jscpd found ${clones} clones across ${totalLines} lines and ${totalTokens} tokens ` +
|
|
72
|
+
`(${duplicatedLines} duplicated lines, ${duplicatedTokens} duplicated tokens).`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (percentage >= warningThreshold) {
|
|
76
|
+
emitWarning(
|
|
77
|
+
`jscpd found ${formatPercent(percentage)} duplicated lines (${clones} clones), ` +
|
|
78
|
+
`above the ${formatPercent(warningThreshold)} advisory threshold. ` +
|
|
79
|
+
"This workflow is warning-only and does not block the PR.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const formatRows = Object.entries(formats)
|
|
84
|
+
.sort(([, left], [, right]) => asNumber(right.percentage) - asNumber(left.percentage))
|
|
85
|
+
.map(([format, stats]) =>
|
|
86
|
+
[
|
|
87
|
+
markdownCell(format),
|
|
88
|
+
asNumber(stats.sources),
|
|
89
|
+
asNumber(stats.clones),
|
|
90
|
+
asNumber(stats.duplicatedLines),
|
|
91
|
+
formatPercent(stats.percentage),
|
|
92
|
+
].join(" | "),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const topDuplicates = [...duplicates]
|
|
96
|
+
.sort((left, right) => asNumber(right.lines) - asNumber(left.lines))
|
|
97
|
+
.slice(0, 10)
|
|
98
|
+
.map((duplicate) =>
|
|
99
|
+
[
|
|
100
|
+
asNumber(duplicate.lines),
|
|
101
|
+
asNumber(duplicate.tokens),
|
|
102
|
+
markdownCell(duplicate.format ?? "unknown"),
|
|
103
|
+
markdownCell(formatLocation(duplicate.firstFile)),
|
|
104
|
+
markdownCell(formatLocation(duplicate.secondFile)),
|
|
105
|
+
].join(" | "),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const summary = [
|
|
109
|
+
"### Duplicate Code Scan",
|
|
110
|
+
"",
|
|
111
|
+
status,
|
|
112
|
+
"",
|
|
113
|
+
"| Metric | Value |",
|
|
114
|
+
"| --- | ---: |",
|
|
115
|
+
`| Clones | ${clones} |`,
|
|
116
|
+
`| Duplicated lines | ${duplicatedLines} / ${totalLines} (${formatPercent(percentage)}) |`,
|
|
117
|
+
`| Duplicated tokens | ${duplicatedTokens} / ${totalTokens} (${formatPercent(total.percentageTokens)}) |`,
|
|
118
|
+
`| Soft threshold | ${formatPercent(warningThreshold)} |`,
|
|
119
|
+
"",
|
|
120
|
+
"| Format | Files | Clones | Duplicated lines | Duplicated lines % |",
|
|
121
|
+
"| --- | ---: | ---: | ---: | ---: |",
|
|
122
|
+
...formatRows,
|
|
123
|
+
"",
|
|
124
|
+
"| Lines | Tokens | Format | First location | Second location |",
|
|
125
|
+
"| ---: | ---: | --- | --- | --- |",
|
|
126
|
+
...(topDuplicates.length > 0 ? topDuplicates : ["| 0 | 0 | none | n/a | n/a |"]),
|
|
127
|
+
"",
|
|
128
|
+
"The complete JSON and HTML reports are attached as the `jscpd-report` workflow artifact.",
|
|
129
|
+
"",
|
|
130
|
+
].join("\n");
|
|
131
|
+
|
|
132
|
+
appendSummary(summary);
|