@pragma-sh/claude-code-plugin 0.1.0-alpha.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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +8 -0
- package/assets/claude-code.svg +7 -0
- package/dist/pragma-plugin.mjs +476 -0
- package/hooks/hooks.json +105 -0
- package/hooks/report.sh +873 -0
- package/package.json +41 -0
- package/pragma-plugin.json +14 -0
- package/scripts/install.sh +4 -0
- package/src/pragma-plugin.test.ts +131 -0
- package/src/pragma-plugin.ts +237 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pragma-sh/claude-code-plugin",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Claude Code plugin that reports agent status to Pragma automatically via shell hooks.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"files": [
|
|
7
|
+
".claude-plugin",
|
|
8
|
+
"assets",
|
|
9
|
+
"dist",
|
|
10
|
+
"hooks",
|
|
11
|
+
"pragma-plugin.json",
|
|
12
|
+
"scripts",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
"./pragma-plugin": "./src/pragma-plugin.ts"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "bun build src/pragma-plugin.ts --outdir dist --format esm --target node --packages bundle && mv dist/pragma-plugin.js dist/pragma-plugin.mjs",
|
|
24
|
+
"prepack": "bun run build",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "bun --bun vitest run",
|
|
27
|
+
"lint": "oxlint ."
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@pragma/plugin": "workspace:*",
|
|
31
|
+
"@pragma/watcher-kit": "workspace:*",
|
|
32
|
+
"@types/node": "^24.12.3",
|
|
33
|
+
"bunup": "^0.16.32",
|
|
34
|
+
"typescript": "^6.0.3",
|
|
35
|
+
"vitest": "^4.1.8"
|
|
36
|
+
},
|
|
37
|
+
"pragma": {
|
|
38
|
+
"pluginId": "pragma.claude-code",
|
|
39
|
+
"main": "./dist/pragma-plugin.mjs"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/pragma-sh/pragma/main/packages/plugin-registry/pragma-plugin.schema.json",
|
|
3
|
+
"name": "Claude Code",
|
|
4
|
+
"description": "Launch Claude Code in Pragma with live status, questions, usage limits, and session naming.",
|
|
5
|
+
"categories": ["agent-plugin"],
|
|
6
|
+
"images": [
|
|
7
|
+
{
|
|
8
|
+
"url": "https://raw.githubusercontent.com/pragma-sh/pragma/main/packages/claude-code-plugin/assets/claude-code.svg",
|
|
9
|
+
"alt": "Claude Code logo"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"install": { "command": "sh", "args": ["scripts/install.sh"] },
|
|
13
|
+
"agentBinary": "claude"
|
|
14
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { claudeCodeAgentPlugin, loadClaudeUsageLimits, parseClaudeUsage } from "./pragma-plugin";
|
|
4
|
+
|
|
5
|
+
it("declares command approvals unsupported but questions supported", () => {
|
|
6
|
+
const agent = claudeCodeAgentPlugin.agents?.[0];
|
|
7
|
+
expect(agent?.excludeFeatures).toContain("commandApproval");
|
|
8
|
+
// AskUserQuestion round-trips through the blocking PermissionRequest hook
|
|
9
|
+
// even in `--permission-mode auto`, so question verification stays enabled.
|
|
10
|
+
expect(agent?.excludeFeatures).not.toContain("questions");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("links to Claude's usage dashboard", () => {
|
|
14
|
+
expect(claudeCodeAgentPlugin.usageLimits?.[0]?.dashboardUrl).toBe(
|
|
15
|
+
"https://claude.ai/new#settings/usage",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("normalizes Claude plan usage windows", () => {
|
|
20
|
+
const observedAt = Date.parse("2026-07-14T00:00:00Z");
|
|
21
|
+
const result = parseClaudeUsage(
|
|
22
|
+
{
|
|
23
|
+
rate_limits_available: true,
|
|
24
|
+
rate_limits: {
|
|
25
|
+
five_hour: { utilization: 42.5, resets_at: "2026-07-14T02:00:00Z" },
|
|
26
|
+
seven_day: { utilization: 18, resets_at: "2026-07-20T00:00:00Z" },
|
|
27
|
+
seven_day_opus: null,
|
|
28
|
+
model_scoped: [
|
|
29
|
+
{
|
|
30
|
+
display_name: "Claude Fable",
|
|
31
|
+
utilization: 105,
|
|
32
|
+
resets_at: "2026-07-15T00:00:00Z",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
observedAt,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual({
|
|
41
|
+
status: "ready",
|
|
42
|
+
observedAt,
|
|
43
|
+
limits: [
|
|
44
|
+
{
|
|
45
|
+
id: "five-hour",
|
|
46
|
+
title: "5-hour limit",
|
|
47
|
+
used: 42.5,
|
|
48
|
+
limit: 100,
|
|
49
|
+
resetsInMs: 2 * 60 * 60 * 1000,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "seven-day",
|
|
53
|
+
title: "Weekly limit",
|
|
54
|
+
used: 18,
|
|
55
|
+
limit: 100,
|
|
56
|
+
resetsInMs: 6 * 24 * 60 * 60 * 1000,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "model-0",
|
|
60
|
+
title: "Claude Fable",
|
|
61
|
+
used: 100,
|
|
62
|
+
limit: 100,
|
|
63
|
+
resetsInMs: 24 * 60 * 60 * 1000,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("requires subscription rate limits and a five-hour window", () => {
|
|
70
|
+
expect(parseClaudeUsage({ rate_limits_available: false, rate_limits: null }, 1)).toMatchObject({
|
|
71
|
+
status: "unavailable",
|
|
72
|
+
reason: "authentication-required",
|
|
73
|
+
});
|
|
74
|
+
// Signed in but the usage fetch came back empty (e.g. a 429 from the usage
|
|
75
|
+
// endpoint): must throw so the host keeps its last snapshot, not report signed-out.
|
|
76
|
+
expect(() => parseClaudeUsage({ rate_limits_available: true, rate_limits: null }, 1)).toThrow(
|
|
77
|
+
/temporarily unavailable/,
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
parseClaudeUsage(
|
|
81
|
+
{
|
|
82
|
+
rate_limits_available: true,
|
|
83
|
+
rate_limits: { seven_day: { utilization: 25, resets_at: null } },
|
|
84
|
+
},
|
|
85
|
+
1,
|
|
86
|
+
),
|
|
87
|
+
).toMatchObject({ status: "unavailable", reason: "unsupported" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("loads usage through Claude Code without handling OAuth credentials", async () => {
|
|
91
|
+
let capturedRequest: { cwd: string; commands: string[] } | undefined;
|
|
92
|
+
const run = vi.fn(async (request: { cwd: string; commands: string[] }) => {
|
|
93
|
+
capturedRequest = request;
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
command: "claude usage",
|
|
97
|
+
stdout: `${JSON.stringify({ type: "system", subtype: "init" })}\n${JSON.stringify({
|
|
98
|
+
type: "control_response",
|
|
99
|
+
response: {
|
|
100
|
+
subtype: "success",
|
|
101
|
+
request_id: "pragma-usage",
|
|
102
|
+
response: {
|
|
103
|
+
rate_limits_available: true,
|
|
104
|
+
rate_limits: { five_hour: { utilization: 30, resets_at: null } },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
})}\n`,
|
|
108
|
+
stderr: "",
|
|
109
|
+
status: 0,
|
|
110
|
+
durationMs: 1,
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const result = await loadClaudeUsageLimits({
|
|
116
|
+
pluginId: "pragma.claude-code",
|
|
117
|
+
pluginDir: "/Applications/Pragma App/plugins/claude-code",
|
|
118
|
+
config: undefined,
|
|
119
|
+
project: { id: "p", name: "Project", path: "/project" },
|
|
120
|
+
sdk: { exec: { run } } as never,
|
|
121
|
+
notify: () => {},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result).toMatchObject({ status: "ready", limits: [{ id: "five-hour", used: 30 }] });
|
|
125
|
+
expect(capturedRequest).toEqual({
|
|
126
|
+
cwd: "/project",
|
|
127
|
+
commands: [expect.stringContaining('"subtype":"get_usage"')],
|
|
128
|
+
});
|
|
129
|
+
expect(capturedRequest?.commands[0]).toContain("--safe-mode");
|
|
130
|
+
expect(capturedRequest?.commands[0]).not.toContain("accessToken");
|
|
131
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineAgent,
|
|
3
|
+
definePlugin,
|
|
4
|
+
defineUsageLimitProvider,
|
|
5
|
+
type PluginContext,
|
|
6
|
+
type PluginDefinition,
|
|
7
|
+
type UsageLimit,
|
|
8
|
+
type UsageLimitsResult,
|
|
9
|
+
} from "@pragma/plugin/catalog";
|
|
10
|
+
import { createTuiWatcher } from "@pragma/watcher-kit";
|
|
11
|
+
|
|
12
|
+
/** Lets Claude Code's paste-aware TUI commit interjected text before Enter. */
|
|
13
|
+
const INTERJECT_SUBMIT_DELAY_MS = 200;
|
|
14
|
+
const USAGE_REQUEST = JSON.stringify({
|
|
15
|
+
type: "control_request",
|
|
16
|
+
request_id: "pragma-usage",
|
|
17
|
+
request: { subtype: "get_usage" },
|
|
18
|
+
});
|
|
19
|
+
const USAGE_COMMAND = `printf '%s\\n' '${USAGE_REQUEST}' | claude -p --safe-mode --input-format stream-json --output-format stream-json --verbose`;
|
|
20
|
+
|
|
21
|
+
const reasoningFull = [
|
|
22
|
+
{ id: "low", name: "Low" },
|
|
23
|
+
{ id: "medium", name: "Medium" },
|
|
24
|
+
{ id: "high", name: "High" },
|
|
25
|
+
{ id: "xhigh", name: "Extra High" },
|
|
26
|
+
{ id: "max", name: "Max" },
|
|
27
|
+
];
|
|
28
|
+
const reasoningStandard = reasoningFull.slice(0, 3);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pragma plugin for Claude Code, bundled to `dist/pragma-plugin.mjs` and loaded
|
|
32
|
+
* by the pragma-plugins sidecar, the desktop webview, and the `pragma-watch`
|
|
33
|
+
* sidecar alike. Approvals go through Claude Code's blocking `await-decision`
|
|
34
|
+
* hook, so the watcher only delivers interjections (`handleDecisions: false`).
|
|
35
|
+
*/
|
|
36
|
+
export const claudeCodeAgentPlugin: PluginDefinition = definePlugin({
|
|
37
|
+
name: "Claude Code",
|
|
38
|
+
description: "Launch Claude Code from Pragma.",
|
|
39
|
+
usageLimits: [
|
|
40
|
+
defineUsageLimitProvider({
|
|
41
|
+
id: "claude-code",
|
|
42
|
+
title: "Claude Code",
|
|
43
|
+
dashboardUrl: "https://claude.ai/new#settings/usage",
|
|
44
|
+
iconPath: "assets/claude-code.svg",
|
|
45
|
+
primaryLimitId: "five-hour",
|
|
46
|
+
// Each refresh spawns a headless `claude -p` session that queries Anthropic's
|
|
47
|
+
// strictly rate-limited OAuth usage endpoint; polling faster causes 429s.
|
|
48
|
+
refreshIntervalMs: 300_000,
|
|
49
|
+
load: loadClaudeUsageLimits,
|
|
50
|
+
}),
|
|
51
|
+
],
|
|
52
|
+
watchers: [
|
|
53
|
+
createTuiWatcher({
|
|
54
|
+
agent: "claude-code",
|
|
55
|
+
handleDecisions: false,
|
|
56
|
+
interjectSubmitDelayMs: INTERJECT_SUBMIT_DELAY_MS,
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
agents: [
|
|
60
|
+
defineAgent({
|
|
61
|
+
id: "claude-code",
|
|
62
|
+
name: "Claude Code",
|
|
63
|
+
icon: () => null,
|
|
64
|
+
iconPath: "assets/claude-code.svg",
|
|
65
|
+
launch: { command: ["claude", "--permission-mode", "auto"] },
|
|
66
|
+
models: [
|
|
67
|
+
{ id: "sonnet", name: "Sonnet", reasoning: reasoningFull },
|
|
68
|
+
{ id: "opus", name: "Opus", reasoning: reasoningStandard },
|
|
69
|
+
{ id: "fable", name: "Fable", reasoning: reasoningFull },
|
|
70
|
+
{ id: "haiku", name: "Haiku", reasoning: reasoningStandard },
|
|
71
|
+
],
|
|
72
|
+
permissionModes: [],
|
|
73
|
+
// `--permission-mode auto` auto-approves every shell command, so a
|
|
74
|
+
// command-approval attention can never be raised for a launched
|
|
75
|
+
// session (`pragma-cli agent verify` `command-allow`/`command-deny`):
|
|
76
|
+
// the model runs the tool and the turn settles. The blocking
|
|
77
|
+
// `PermissionRequest` hook still round-trips `AskUserQuestion`
|
|
78
|
+
// (`questions` stays enabled), but approvals are only exercisable when
|
|
79
|
+
// a permission prompt actually fires, which the auto launch prevents.
|
|
80
|
+
excludeFeatures: ["commandApproval"],
|
|
81
|
+
args: {
|
|
82
|
+
model: (modelId: string) => ["--model", modelId],
|
|
83
|
+
reasoning: (reasoningId: string) => ["--effort", reasoningId],
|
|
84
|
+
permissionMode: () => [],
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export default claudeCodeAgentPlugin;
|
|
91
|
+
|
|
92
|
+
/** Loads plan usage through Claude Code's structured `/usage` control request. */
|
|
93
|
+
export async function loadClaudeUsageLimits(ctx: PluginContext): Promise<UsageLimitsResult> {
|
|
94
|
+
const cwd = ctx.project?.path ?? "/tmp";
|
|
95
|
+
const [result] = await ctx.sdk.exec.run({ cwd, commands: [USAGE_COMMAND] });
|
|
96
|
+
if (!result || result.status !== 0) {
|
|
97
|
+
throw new Error(result?.stderr.trim() || "Claude Code usage request failed");
|
|
98
|
+
}
|
|
99
|
+
return extractUsageFromOutput(result.stdout);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Scans NDJSON control-response output for the usage reply and normalizes it. */
|
|
103
|
+
function extractUsageFromOutput(stdout: string): UsageLimitsResult {
|
|
104
|
+
for (const line of stdout.split("\n")) {
|
|
105
|
+
const response = tryParseUsageControlResponse(line);
|
|
106
|
+
if (response === undefined) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (response.subtype === "error") {
|
|
110
|
+
throw new Error(response.error || "Claude Code usage request failed");
|
|
111
|
+
}
|
|
112
|
+
return parseClaudeUsage(response.response, Date.now());
|
|
113
|
+
}
|
|
114
|
+
throw new Error("Claude Code did not return usage data");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Parses one NDJSON line as a usage control response, ignoring non-matching or malformed lines. */
|
|
118
|
+
function tryParseUsageControlResponse(
|
|
119
|
+
line: string,
|
|
120
|
+
): { subtype: "success"; response: unknown } | { subtype: "error"; error?: string } | undefined {
|
|
121
|
+
try {
|
|
122
|
+
const value: unknown = JSON.parse(line);
|
|
123
|
+
return isUsageControlResponse(value) ? value.response : undefined;
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
if (cause instanceof SyntaxError) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
throw cause;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Normalizes Claude Code's structured `/usage` response into generic usage limits. */
|
|
133
|
+
export function parseClaudeUsage(value: unknown, observedAt: number): UsageLimitsResult {
|
|
134
|
+
if (!isRecord(value)) {
|
|
135
|
+
throw new Error("Claude Code usage response was not an object");
|
|
136
|
+
}
|
|
137
|
+
if (value.rate_limits_available !== true) {
|
|
138
|
+
return {
|
|
139
|
+
status: "unavailable",
|
|
140
|
+
reason: "authentication-required",
|
|
141
|
+
message: "Sign in to Claude Code with a subscription to load usage limits.",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (!isRecord(value.rate_limits)) {
|
|
145
|
+
// Signed in, but the CLI's usage fetch returned no data — usually a transient
|
|
146
|
+
// 429 from the usage endpoint. Throwing (instead of returning "unavailable")
|
|
147
|
+
// keeps the host's last snapshot and triggers its retry backoff.
|
|
148
|
+
throw new Error("Claude Code usage data is temporarily unavailable (rate limited)");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const rateLimits = value.rate_limits;
|
|
152
|
+
const limits: UsageLimit[] = [];
|
|
153
|
+
addWindow(limits, "five-hour", "5-hour limit", rateLimits.five_hour, observedAt);
|
|
154
|
+
addWindow(limits, "seven-day", "Weekly limit", rateLimits.seven_day, observedAt);
|
|
155
|
+
addWindow(
|
|
156
|
+
limits,
|
|
157
|
+
"seven-day-oauth-apps",
|
|
158
|
+
"Weekly OAuth apps",
|
|
159
|
+
rateLimits.seven_day_oauth_apps,
|
|
160
|
+
observedAt,
|
|
161
|
+
);
|
|
162
|
+
addWindow(limits, "seven-day-opus", "Weekly Opus", rateLimits.seven_day_opus, observedAt);
|
|
163
|
+
addWindow(limits, "seven-day-sonnet", "Weekly Sonnet", rateLimits.seven_day_sonnet, observedAt);
|
|
164
|
+
|
|
165
|
+
if (Array.isArray(rateLimits.model_scoped)) {
|
|
166
|
+
for (const [index, scoped] of rateLimits.model_scoped.entries()) {
|
|
167
|
+
if (!isRecord(scoped) || typeof scoped.display_name !== "string") {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
addWindow(
|
|
171
|
+
limits,
|
|
172
|
+
`model-${index}`,
|
|
173
|
+
scoped.display_name,
|
|
174
|
+
{ utilization: scoped.utilization, resets_at: scoped.resets_at },
|
|
175
|
+
observedAt,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!limits.some((limit) => limit.id === "five-hour")) {
|
|
181
|
+
return {
|
|
182
|
+
status: "unavailable",
|
|
183
|
+
reason: "unsupported",
|
|
184
|
+
message: "Claude Code did not return its 5-hour usage limit.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return { status: "ready", observedAt, limits };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function addWindow(
|
|
191
|
+
limits: UsageLimit[],
|
|
192
|
+
id: string,
|
|
193
|
+
title: string,
|
|
194
|
+
value: unknown,
|
|
195
|
+
observedAt: number,
|
|
196
|
+
): void {
|
|
197
|
+
if (!isRecord(value) || !isFiniteNumber(value.utilization)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const resetAt = parseDate(value.resets_at);
|
|
201
|
+
limits.push({
|
|
202
|
+
id,
|
|
203
|
+
title,
|
|
204
|
+
used: Math.min(100, Math.max(0, value.utilization)),
|
|
205
|
+
limit: 100,
|
|
206
|
+
...(resetAt === null ? {} : { resetsInMs: Math.max(0, resetAt - observedAt) }),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseDate(value: unknown): number | null {
|
|
211
|
+
if (typeof value !== "string") {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const parsed = Date.parse(value);
|
|
215
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isUsageControlResponse(value: unknown): value is {
|
|
219
|
+
type: "control_response";
|
|
220
|
+
response: { subtype: "success"; response: unknown } | { subtype: "error"; error?: string };
|
|
221
|
+
} {
|
|
222
|
+
if (!isRecord(value) || value.type !== "control_response" || !isRecord(value.response)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return (
|
|
226
|
+
(value.response.subtype === "success" && "response" in value.response) ||
|
|
227
|
+
value.response.subtype === "error"
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
232
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
236
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
237
|
+
}
|