1. Memoization/Caching
- loadAll() now memoized (volatile loaded flag + double-check lock)
- clearCache() invalidates and notifies listeners
- Dynamic skills map for runtime-discovered skills
- Conditional skills pending map with activation tracking
- onSkillsChanged() listener registration (matches TS onDynamicSkillsLoaded)
2. Setting Source Enforcement
- isSettingSourceEnabled() checks env CLAUDE_CODE_DISABLE_{SOURCE}
- isEnvTruthy() for boolean env vars (1/true/yes)
- CLAUDE_CODE_DISABLE_POLICY_SKILLS skips managed skills
- getManagedSkillsPath() for policy-managed skill directory
- Conditional loading per source (user/project/policy)
3. Shell Command Execution (PromptShellExecution)
- Parse \\\! command \\\ code blocks and !\command\ inline syntax
- Execute via bash or powershell per skill frontmatter shell field
- Output replaces command placeholder in skill content
- 30s timeout, proper error handling
- MCP skills excluded from shell execution (security)
4. Argument Substitution (ArgumentSubstitution)
- \ — full raw arguments string
- \[n] — indexed access
- \ — shorthand for indexed (bash-style)
- Named arguments (\, \) mapped from frontmatter
- Shell-quote aware parsing (handles quoted multi-word args)
- Auto-append 'ARGUMENTS: ...' if no placeholder found
- Progressive argument hints for UI
Also:
- Paths frontmatter: brace expansion (src/*.{ts,tsx} → [src/*.ts, src/*.tsx])
- splitPathInFrontmatter with comma-respecting-braces parsing
- activateConditionalSkillsForPaths() with proper activation model
- 17 new unit tests (104 total, 0 failures)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
main
parent
c66d58426f
commit
b7a7d35025
@ -0,0 +1,190 @@ |
|||||||
|
package com.claudecode.util; |
||||||
|
|
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.List; |
||||||
|
import java.util.regex.Matcher; |
||||||
|
import java.util.regex.Pattern; |
||||||
|
|
||||||
|
/** |
||||||
|
* Argument substitution for skill/command prompts. |
||||||
|
* Aligns with claude-code/src/utils/argumentSubstitution.ts. |
||||||
|
* <p> |
||||||
|
* Supports: |
||||||
|
* <ul> |
||||||
|
* <li>$ARGUMENTS — replaced with the full arguments string</li> |
||||||
|
* <li>$ARGUMENTS[0], $ARGUMENTS[1], … — indexed access</li> |
||||||
|
* <li>$0, $1, … — shorthand for $ARGUMENTS[n]</li> |
||||||
|
* <li>Named arguments ($foo, $bar) — mapped from frontmatter argument names</li> |
||||||
|
* <li>${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID} — special variables</li> |
||||||
|
* </ul> |
||||||
|
*/ |
||||||
|
public final class ArgumentSubstitution { |
||||||
|
|
||||||
|
private ArgumentSubstitution() {} |
||||||
|
|
||||||
|
// $ARGUMENTS[n]
|
||||||
|
private static final Pattern INDEXED_PATTERN = Pattern.compile("\\$ARGUMENTS\\[(\\d+)]"); |
||||||
|
// $n (not followed by word chars, to avoid matching $100foo)
|
||||||
|
private static final Pattern SHORTHAND_PATTERN = Pattern.compile("\\$(\\d+)(?!\\w)"); |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a raw argument string into individual arguments with shell-quote awareness. |
||||||
|
* Handles double-quoted and single-quoted strings. |
||||||
|
* <p> |
||||||
|
* Examples: |
||||||
|
* <pre> |
||||||
|
* "foo bar baz" → ["foo", "bar", "baz"] |
||||||
|
* "foo \"hello world\" z" → ["foo", "hello world", "z"] |
||||||
|
* "foo 'hello world' z" → ["foo", "hello world", "z"] |
||||||
|
* </pre> |
||||||
|
*/ |
||||||
|
public static List<String> parseArguments(String args) { |
||||||
|
if (args == null || args.isBlank()) { |
||||||
|
return List.of(); |
||||||
|
} |
||||||
|
|
||||||
|
List<String> result = new ArrayList<>(); |
||||||
|
StringBuilder current = new StringBuilder(); |
||||||
|
boolean inDoubleQuote = false; |
||||||
|
boolean inSingleQuote = false; |
||||||
|
boolean escaped = false; |
||||||
|
|
||||||
|
for (int i = 0; i < args.length(); i++) { |
||||||
|
char c = args.charAt(i); |
||||||
|
|
||||||
|
if (escaped) { |
||||||
|
current.append(c); |
||||||
|
escaped = false; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (c == '\\' && !inSingleQuote) { |
||||||
|
escaped = true; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (c == '"' && !inSingleQuote) { |
||||||
|
inDoubleQuote = !inDoubleQuote; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (c == '\'' && !inDoubleQuote) { |
||||||
|
inSingleQuote = !inSingleQuote; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (Character.isWhitespace(c) && !inDoubleQuote && !inSingleQuote) { |
||||||
|
if (!current.isEmpty()) { |
||||||
|
result.add(current.toString()); |
||||||
|
current.setLength(0); |
||||||
|
} |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
current.append(c); |
||||||
|
} |
||||||
|
|
||||||
|
if (!current.isEmpty()) { |
||||||
|
result.add(current.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse argument names from frontmatter 'arguments' field. |
||||||
|
* Rejects numeric-only names (which conflict with $0, $1 shorthand). |
||||||
|
*/ |
||||||
|
public static List<String> parseArgumentNames(List<String> argumentNames) { |
||||||
|
if (argumentNames == null || argumentNames.isEmpty()) { |
||||||
|
return List.of(); |
||||||
|
} |
||||||
|
return argumentNames.stream() |
||||||
|
.filter(name -> name != null && !name.isBlank() && !name.matches("^\\d+$")) |
||||||
|
.toList(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate argument hint showing remaining unfilled args. |
||||||
|
* |
||||||
|
* @param argNames argument names from frontmatter |
||||||
|
* @param typedArgs arguments the user has typed so far |
||||||
|
* @return hint like "[arg2] [arg3]" or null if all filled |
||||||
|
*/ |
||||||
|
public static String generateProgressiveArgumentHint(List<String> argNames, List<String> typedArgs) { |
||||||
|
if (argNames == null || argNames.size() <= typedArgs.size()) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
return argNames.subList(typedArgs.size(), argNames.size()).stream() |
||||||
|
.map(name -> "[" + name + "]") |
||||||
|
.reduce((a, b) -> a + " " + b) |
||||||
|
.orElse(null); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Substitute argument placeholders in content. |
||||||
|
* <p> |
||||||
|
* Order of substitution (matching TS): |
||||||
|
* <ol> |
||||||
|
* <li>Named arguments: $foo, $bar → mapped by position from argumentNames</li> |
||||||
|
* <li>Indexed access: $ARGUMENTS[0], $ARGUMENTS[1], …</li> |
||||||
|
* <li>Shorthand indexed: $0, $1, …</li> |
||||||
|
* <li>Full arguments: $ARGUMENTS → raw args string</li> |
||||||
|
* <li>Auto-append: if no placeholder matched and args non-empty, append "ARGUMENTS: {args}"</li> |
||||||
|
* </ol> |
||||||
|
* |
||||||
|
* @param content the content containing placeholders |
||||||
|
* @param args the raw arguments string (null = no args, return unchanged) |
||||||
|
* @param appendIfNoPlaceholder if true, appends "ARGUMENTS: {args}" when no placeholder found |
||||||
|
* @param argumentNames named arguments from frontmatter |
||||||
|
*/ |
||||||
|
public static String substituteArguments(String content, String args, |
||||||
|
boolean appendIfNoPlaceholder, |
||||||
|
List<String> argumentNames) { |
||||||
|
if (content == null) return ""; |
||||||
|
// null means no args provided — return content unchanged
|
||||||
|
if (args == null) return content; |
||||||
|
|
||||||
|
List<String> parsedArgs = parseArguments(args); |
||||||
|
List<String> validNames = parseArgumentNames(argumentNames); |
||||||
|
String original = content; |
||||||
|
|
||||||
|
// 1. Replace named arguments: $foo, $bar (not followed by [ or word chars)
|
||||||
|
for (int i = 0; i < validNames.size(); i++) { |
||||||
|
String name = validNames.get(i); |
||||||
|
String value = i < parsedArgs.size() ? parsedArgs.get(i) : ""; |
||||||
|
// Match $name but not $name[…] or $nameXxx
|
||||||
|
content = content.replaceAll("\\$" + Pattern.quote(name) + "(?![\\[\\w])", |
||||||
|
Matcher.quoteReplacement(value)); |
||||||
|
} |
||||||
|
|
||||||
|
// 2. Replace indexed: $ARGUMENTS[0], $ARGUMENTS[1], …
|
||||||
|
content = INDEXED_PATTERN.matcher(content).replaceAll(mr -> { |
||||||
|
int idx = Integer.parseInt(mr.group(1)); |
||||||
|
return Matcher.quoteReplacement(idx < parsedArgs.size() ? parsedArgs.get(idx) : ""); |
||||||
|
}); |
||||||
|
|
||||||
|
// 3. Replace shorthand: $0, $1, …
|
||||||
|
content = SHORTHAND_PATTERN.matcher(content).replaceAll(mr -> { |
||||||
|
int idx = Integer.parseInt(mr.group(1)); |
||||||
|
return Matcher.quoteReplacement(idx < parsedArgs.size() ? parsedArgs.get(idx) : ""); |
||||||
|
}); |
||||||
|
|
||||||
|
// 4. Replace $ARGUMENTS with full raw args string
|
||||||
|
content = content.replace("$ARGUMENTS", args); |
||||||
|
|
||||||
|
// 5. Auto-append if no placeholder found and args non-empty
|
||||||
|
if (content.equals(original) && appendIfNoPlaceholder && !args.isBlank()) { |
||||||
|
content = content + "\n\nARGUMENTS: " + args; |
||||||
|
} |
||||||
|
|
||||||
|
return content; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Overload with default appendIfNoPlaceholder=true, no named args. |
||||||
|
*/ |
||||||
|
public static String substituteArguments(String content, String args) { |
||||||
|
return substituteArguments(content, args, true, List.of()); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,150 @@ |
|||||||
|
package com.claudecode.util; |
||||||
|
|
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
|
||||||
|
import java.io.IOException; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.List; |
||||||
|
import java.util.concurrent.TimeUnit; |
||||||
|
import java.util.regex.Matcher; |
||||||
|
import java.util.regex.Pattern; |
||||||
|
|
||||||
|
/** |
||||||
|
* Executes embedded shell commands in skill/command markdown content. |
||||||
|
* Aligns with claude-code/src/utils/promptShellExecution.ts. |
||||||
|
* <p> |
||||||
|
* Supported syntaxes: |
||||||
|
* <ul> |
||||||
|
* <li>Code blocks: ```! command ```</li> |
||||||
|
* <li>Inline: !`command`</li> |
||||||
|
* </ul> |
||||||
|
* <p> |
||||||
|
* Commands are executed via the system shell (bash or powershell as specified |
||||||
|
* in frontmatter). Output replaces the command placeholder in the content. |
||||||
|
*/ |
||||||
|
public final class PromptShellExecution { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PromptShellExecution.class); |
||||||
|
|
||||||
|
private PromptShellExecution() {} |
||||||
|
|
||||||
|
/** Pattern for code blocks: ```! command ``` */ |
||||||
|
private static final Pattern BLOCK_PATTERN = Pattern.compile("```!\\s*\\n?([\\s\\S]*?)\\n?```"); |
||||||
|
|
||||||
|
/** Pattern for inline: !`command` (preceded by whitespace or start-of-line) */ |
||||||
|
private static final Pattern INLINE_PATTERN = Pattern.compile("(?<=^|\\s)!`([^`]+)`", Pattern.MULTILINE); |
||||||
|
|
||||||
|
/** Default command timeout in seconds */ |
||||||
|
private static final int DEFAULT_TIMEOUT = 30; |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse and execute embedded shell commands in the given text. |
||||||
|
* Replaces each command placeholder with its output. |
||||||
|
* |
||||||
|
* @param text the skill/command markdown content |
||||||
|
* @param shell "bash" or "powershell" (null defaults to bash) |
||||||
|
* @param workDir working directory for command execution |
||||||
|
* @return content with command outputs substituted |
||||||
|
*/ |
||||||
|
public static String executeShellCommandsInPrompt(String text, String shell, Path workDir) { |
||||||
|
if (text == null || text.isEmpty()) return text; |
||||||
|
|
||||||
|
// Collect all matches (block + inline)
|
||||||
|
List<CommandMatch> matches = new ArrayList<>(); |
||||||
|
|
||||||
|
Matcher blockMatcher = BLOCK_PATTERN.matcher(text); |
||||||
|
while (blockMatcher.find()) { |
||||||
|
String command = blockMatcher.group(1); |
||||||
|
if (command != null && !command.isBlank()) { |
||||||
|
matches.add(new CommandMatch(blockMatcher.start(), blockMatcher.end(), |
||||||
|
blockMatcher.group(0), command.strip())); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Only scan for inline pattern if text contains !` (optimization from TS)
|
||||||
|
if (text.contains("!`")) { |
||||||
|
Matcher inlineMatcher = INLINE_PATTERN.matcher(text); |
||||||
|
while (inlineMatcher.find()) { |
||||||
|
String command = inlineMatcher.group(1); |
||||||
|
if (command != null && !command.isBlank()) { |
||||||
|
matches.add(new CommandMatch(inlineMatcher.start(), inlineMatcher.end(), |
||||||
|
inlineMatcher.group(0), command.strip())); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (matches.isEmpty()) return text; |
||||||
|
|
||||||
|
// Execute commands and replace (reverse order to preserve offsets)
|
||||||
|
matches.sort((a, b) -> Integer.compare(b.start, a.start)); |
||||||
|
|
||||||
|
String result = text; |
||||||
|
for (CommandMatch match : matches) { |
||||||
|
try { |
||||||
|
String output = executeShellCommand(match.command, shell, workDir); |
||||||
|
result = result.replace(match.fullMatch, output); |
||||||
|
log.debug("Shell command executed in skill: {} → {} chars output", |
||||||
|
match.command.substring(0, Math.min(50, match.command.length())), output.length()); |
||||||
|
} catch (Exception e) { |
||||||
|
log.warn("Shell command failed in skill content: {}: {}", match.command, e.getMessage()); |
||||||
|
result = result.replace(match.fullMatch, |
||||||
|
"[Error executing command: " + e.getMessage() + "]"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Execute a single shell command and return its stdout. |
||||||
|
*/ |
||||||
|
private static String executeShellCommand(String command, String shell, Path workDir) throws IOException { |
||||||
|
List<String> cmd; |
||||||
|
boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win"); |
||||||
|
|
||||||
|
if ("powershell".equalsIgnoreCase(shell)) { |
||||||
|
if (isWindows) { |
||||||
|
cmd = List.of("powershell", "-NoProfile", "-Command", command); |
||||||
|
} else { |
||||||
|
cmd = List.of("pwsh", "-NoProfile", "-Command", command); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Default: bash
|
||||||
|
if (isWindows) { |
||||||
|
// Try bash (Git Bash / WSL), fallback to cmd
|
||||||
|
cmd = List.of("bash", "-c", command); |
||||||
|
} else { |
||||||
|
cmd = List.of("bash", "-c", command); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd); |
||||||
|
if (workDir != null) { |
||||||
|
pb.directory(workDir.toFile()); |
||||||
|
} |
||||||
|
pb.redirectErrorStream(true); |
||||||
|
|
||||||
|
try { |
||||||
|
Process process = pb.start(); |
||||||
|
String output = new String(process.getInputStream().readAllBytes()); |
||||||
|
boolean finished = process.waitFor(DEFAULT_TIMEOUT, TimeUnit.SECONDS); |
||||||
|
if (!finished) { |
||||||
|
process.destroyForcibly(); |
||||||
|
throw new IOException("Command timed out after " + DEFAULT_TIMEOUT + "s: " + command); |
||||||
|
} |
||||||
|
int exitCode = process.exitValue(); |
||||||
|
if (exitCode != 0) { |
||||||
|
log.debug("Shell command exited with code {}: {}", exitCode, command); |
||||||
|
} |
||||||
|
return output.strip(); |
||||||
|
} catch (InterruptedException e) { |
||||||
|
Thread.currentThread().interrupt(); |
||||||
|
throw new IOException("Command interrupted: " + command, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private record CommandMatch(int start, int end, String fullMatch, String command) {} |
||||||
|
} |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
package com.claudecode.util; |
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test; |
||||||
|
|
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||||
|
|
||||||
|
class ArgumentSubstitutionTest { |
||||||
|
|
||||||
|
@Test |
||||||
|
void parseArguments_simpleWhitespace() { |
||||||
|
assertEquals(List.of("foo", "bar", "baz"), ArgumentSubstitution.parseArguments("foo bar baz")); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void parseArguments_doubleQuotedString() { |
||||||
|
assertEquals(List.of("foo", "hello world", "baz"), |
||||||
|
ArgumentSubstitution.parseArguments("foo \"hello world\" baz")); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void parseArguments_singleQuotedString() { |
||||||
|
assertEquals(List.of("foo", "hello world", "baz"), |
||||||
|
ArgumentSubstitution.parseArguments("foo 'hello world' baz")); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void parseArguments_escapedSpaces() { |
||||||
|
assertEquals(List.of("hello world"), ArgumentSubstitution.parseArguments("hello\\ world")); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void parseArguments_emptyAndNull() { |
||||||
|
assertEquals(List.of(), ArgumentSubstitution.parseArguments(null)); |
||||||
|
assertEquals(List.of(), ArgumentSubstitution.parseArguments("")); |
||||||
|
assertEquals(List.of(), ArgumentSubstitution.parseArguments(" ")); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void parseArgumentNames_filtersNumericOnly() { |
||||||
|
assertEquals(List.of("foo", "bar"), |
||||||
|
ArgumentSubstitution.parseArgumentNames(List.of("foo", "123", "bar", "0"))); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_fullArguments() { |
||||||
|
String result = ArgumentSubstitution.substituteArguments( |
||||||
|
"Run $ARGUMENTS now", "test.js", true, List.of()); |
||||||
|
assertEquals("Run test.js now", result); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_indexedAccess() { |
||||||
|
String result = ArgumentSubstitution.substituteArguments( |
||||||
|
"File: $ARGUMENTS[0] Line: $ARGUMENTS[1]", "test.js 42", true, List.of()); |
||||||
|
assertEquals("File: test.js Line: 42", result); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_shorthandIndexed() { |
||||||
|
String result = ArgumentSubstitution.substituteArguments( |
||||||
|
"File: $0 Line: $1", "test.js 42", true, List.of()); |
||||||
|
assertEquals("File: test.js Line: 42", result); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_namedArguments() { |
||||||
|
String result = ArgumentSubstitution.substituteArguments( |
||||||
|
"File: $file Line: $line", "test.js 42", true, List.of("file", "line")); |
||||||
|
assertEquals("File: test.js Line: 42", result); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_quotedMultiWord() { |
||||||
|
String result = ArgumentSubstitution.substituteArguments( |
||||||
|
"Greeting: $0", "\"hello world\"", true, List.of()); |
||||||
|
assertEquals("Greeting: hello world", result); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_autoAppendWhenNoPlaceholder() { |
||||||
|
String result = ArgumentSubstitution.substituteArguments( |
||||||
|
"No placeholders here.", "some args", true, List.of()); |
||||||
|
assertEquals("No placeholders here.\n\nARGUMENTS: some args", result); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_noAutoAppendWhenDisabled() { |
||||||
|
String result = ArgumentSubstitution.substituteArguments( |
||||||
|
"No placeholders here.", "some args", false, List.of()); |
||||||
|
assertEquals("No placeholders here.", result); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_nullArgsUnchanged() { |
||||||
|
String content = "Content with $ARGUMENTS placeholder"; |
||||||
|
assertSame(content, ArgumentSubstitution.substituteArguments(content, null, true, List.of())); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void substituteArguments_emptyArgsReplaces() { |
||||||
|
String result = ArgumentSubstitution.substituteArguments( |
||||||
|
"Run $ARGUMENTS now", "", true, List.of()); |
||||||
|
assertEquals("Run now", result); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void generateProgressiveArgumentHint_basic() { |
||||||
|
assertEquals("[arg2] [arg3]", |
||||||
|
ArgumentSubstitution.generateProgressiveArgumentHint( |
||||||
|
List.of("arg1", "arg2", "arg3"), List.of("val1"))); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void generateProgressiveArgumentHint_allFilled() { |
||||||
|
assertNull(ArgumentSubstitution.generateProgressiveArgumentHint( |
||||||
|
List.of("arg1"), List.of("val1"))); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue