claude-dev-env 1.17.2 → 1.19.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.
Files changed (32) hide show
  1. package/bin/install.mjs +145 -63
  2. package/hooks/blocking/content-search-to-zoekt-redirector.py +55 -0
  3. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +25 -0
  4. package/hooks/blocking/content_search_zoekt_block_payload.py +17 -0
  5. package/hooks/blocking/content_search_zoekt_indexed_paths.py +24 -0
  6. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +131 -0
  7. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
  8. package/hooks/blocking/destructive-command-blocker.py +53 -4
  9. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +54 -0
  10. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +51 -0
  11. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +102 -0
  12. package/hooks/blocking/test_destructive_command_blocker.py +108 -0
  13. package/package.json +4 -1
  14. package/skills/rule-audit/SKILL.md +2 -2
  15. package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +0 -64
  16. package/hooks/blocking/prompt_workflow_clipboard.py +0 -63
  17. package/hooks/blocking/prompt_workflow_gate_config.py +0 -113
  18. package/hooks/blocking/prompt_workflow_gate_core.py +0 -289
  19. package/hooks/blocking/prompt_workflow_validate.py +0 -218
  20. package/hooks/blocking/test_prompt_workflow_clipboard.py +0 -54
  21. package/hooks/blocking/test_prompt_workflow_gate_core.py +0 -195
  22. package/hooks/blocking/test_prompt_workflow_validate.py +0 -339
  23. package/rules/prompt-workflow-context-controls.md +0 -48
  24. package/skills/agent-prompt/SKILL.md +0 -199
  25. package/skills/prompt-generator/ARCHITECTURE.md +0 -18
  26. package/skills/prompt-generator/REFERENCE.md +0 -254
  27. package/skills/prompt-generator/REFINEMENT_PIPELINE_RUNBOOK.md +0 -177
  28. package/skills/prompt-generator/SKILL.md +0 -354
  29. package/skills/prompt-generator/TARGET_OUTPUT.md +0 -133
  30. package/skills/prompt-generator/evals/prompt-generator.json +0 -207
  31. package/skills/prompt-generator/templates/skill-from-ground-up.md +0 -104
  32. package/skills/prompt-generator/templates/skill-refinement-package.md +0 -109
@@ -1,289 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Shared deterministic checks for prompt workflow hooks."""
3
-
4
- from __future__ import annotations
5
-
6
- import re
7
- import textwrap
8
- from typing import Iterable
9
-
10
- from prompt_workflow_gate_config import (
11
- AMBIGUOUS_SCOPE_TERMS,
12
- COMPILED_NEGATIVE_INDIRECT_PATTERNS,
13
- COMPILED_NEGATIVE_KEYWORD_PATTERNS,
14
- DEBUG_INTENT_MARKERS,
15
- INTERNAL_OBJECT_MARKERS,
16
- PROMPT_WORKFLOW_RESPONSE_MARKERS,
17
- REQUIRED_CHECKLIST_ROWS,
18
- REQUIRED_SCOPE_ANCHORS,
19
- REQUIRED_XML_SECTIONS,
20
- )
21
-
22
- TRIPLE_BACKTICK = "```"
23
- AUDIT_LINE_PATTERN = re.compile(r"^\s*[●•]?\s*(Audit:\s*.+?)\s*$")
24
-
25
- def _line_opens_xml_fence(line: str) -> bool:
26
- stripped = line.strip()
27
- if not stripped.startswith(TRIPLE_BACKTICK):
28
- return False
29
- fence_marker_length = len(TRIPLE_BACKTICK)
30
- remainder = stripped[fence_marker_length:].strip()
31
- return remainder == "xml" or remainder.startswith("xml ")
32
-
33
- def _line_is_bare_fence_close(line: str) -> bool:
34
- return line.strip() == TRIPLE_BACKTICK
35
-
36
- def _line_opens_inner_markdown_fence(line: str) -> bool:
37
- stripped = line.strip()
38
- if not stripped.startswith(TRIPLE_BACKTICK):
39
- return False
40
- return stripped != TRIPLE_BACKTICK
41
-
42
- def _collect_inner_markdown_fence(
43
- lines: list[str],
44
- start_index: int,
45
- ) -> tuple[list[str], int]:
46
- inner_lines: list[str] = []
47
- index = start_index
48
- while index < len(lines):
49
- current_line = lines[index]
50
- inner_lines.append(current_line)
51
- index += 1
52
- if _line_is_bare_fence_close(current_line):
53
- break
54
- return inner_lines, index
55
-
56
- def _collect_xml_fence_body(
57
- lines: list[str],
58
- start_index: int,
59
- ) -> tuple[list[str], int]:
60
- body_lines: list[str] = []
61
- index = start_index
62
- while index < len(lines):
63
- current_line = lines[index]
64
- if _line_is_bare_fence_close(current_line):
65
- return body_lines, index + 1
66
- if _line_opens_inner_markdown_fence(current_line):
67
- inner_lines, index = _collect_inner_markdown_fence(lines, index)
68
- body_lines.extend(inner_lines)
69
- continue
70
- body_lines.append(current_line)
71
- index += 1
72
- return body_lines, index
73
-
74
- def extract_fenced_xml_content(text: str) -> str:
75
- """Extract bodies of ```xml fenced blocks.
76
-
77
- The closing delimiter is a line whose stripped text is exactly three backticks.
78
- Inner Markdown code fences (for example a line starting with three backticks
79
- plus a language tag) are scanned until their own closing backtick line so the
80
- outer ``xml`` fence does not end early.
81
- """
82
- results: list[str] = []
83
- lines = text.splitlines()
84
- index = 0
85
- while index < len(lines):
86
- if not _line_opens_xml_fence(lines[index]):
87
- index += 1
88
- continue
89
- body_lines, index = _collect_xml_fence_body(lines, index + 1)
90
- results.append("\n".join(body_lines))
91
- return "\n".join(results)
92
-
93
- def _line_is_audit_line(line: str) -> bool:
94
- return AUDIT_LINE_PATTERN.match(line) is not None
95
-
96
- def _normalize_audit_line(line: str) -> str:
97
- match = AUDIT_LINE_PATTERN.match(line)
98
- if match:
99
- return match.group(1).strip()
100
- return line.strip()
101
-
102
- def _line_starts_exported_artifact(line: str) -> bool:
103
- stripped = line.strip()
104
- if not stripped:
105
- return False
106
- if _line_opens_xml_fence(stripped):
107
- return True
108
- exported_artifact_pattern = re.compile(
109
- r"^<(\?xml\b|prompt\b|runtime_context\b|role\b|background\b|instructions\b|constraints\b|output_format\b|illustrations\b|open_question\b)",
110
- )
111
- return exported_artifact_pattern.match(stripped) is not None
112
-
113
- def _trim_trailing_blank_lines(lines: list[str]) -> list[str]:
114
- trimmed = list(lines)
115
- while trimmed and not trimmed[-1].strip():
116
- trimmed.pop()
117
- return trimmed
118
-
119
- def _trim_flattened_export_tail(lines: list[str]) -> list[str]:
120
- trimmed = _trim_trailing_blank_lines(lines)
121
- while trimmed and trimmed[-1].lstrip().startswith("✻ "):
122
- trimmed.pop()
123
- trimmed = _trim_trailing_blank_lines(trimmed)
124
- return trimmed
125
-
126
- def _find_last_audit_index(lines: list[str]) -> int | None:
127
- last_audit_index: int | None = None
128
- for index, line in enumerate(lines):
129
- if _line_is_audit_line(line):
130
- last_audit_index = index
131
- return last_audit_index
132
-
133
- def _find_first_artifact_index(lines: list[str]) -> int | None:
134
- for index, line in enumerate(lines):
135
- if _line_starts_exported_artifact(line):
136
- return index
137
- return None
138
-
139
- def _rebuild_from_existing_fence(audit_line: str, artifact_text: str) -> str:
140
- fenced_body = extract_fenced_xml_content(artifact_text).strip()
141
- if not fenced_body:
142
- return audit_line
143
- return f"{audit_line}\n```xml\n{fenced_body}\n```"
144
-
145
- def _rebuild_from_flattened_body(audit_line: str, artifact_text: str) -> str:
146
- dedented_body = textwrap.dedent(artifact_text).strip("\n")
147
- if not dedented_body:
148
- return audit_line
149
- return f"{audit_line}\n```xml\n{dedented_body}\n```"
150
-
151
- def _rebuild_canonical_export(audit_line: str, artifact_lines: list[str]) -> str:
152
- if not artifact_lines:
153
- return audit_line
154
- artifact_text = "\n".join(artifact_lines).rstrip()
155
- if _line_opens_xml_fence(artifact_lines[0]):
156
- return _rebuild_from_existing_fence(audit_line, artifact_text)
157
- return _rebuild_from_flattened_body(audit_line, artifact_text)
158
-
159
- def normalize_prompt_workflow_export(text: str) -> str:
160
- """Return the last successful Audit + fenced XML pair from a message or export.
161
-
162
- Saved transcript exports can flatten blocked retry turns and strip the outer
163
- ``xml`` fence. This helper keeps only the last successful ``Audit:`` attempt
164
- and rebuilds the canonical audit-plus-fence shape used by prompt-workflow
165
- hooks and reviewers.
166
- """
167
- lines = text.splitlines()
168
- last_audit_index = _find_last_audit_index(lines)
169
- if last_audit_index is None:
170
- return text.strip()
171
- audit_line = _normalize_audit_line(lines[last_audit_index])
172
- artifact_index = _find_first_artifact_index(lines[last_audit_index + 1 :])
173
- if artifact_index is None:
174
- return audit_line
175
- artifact_lines = _trim_flattened_export_tail(
176
- lines[last_audit_index + 1 + artifact_index :],
177
- )
178
- return _rebuild_canonical_export(audit_line, artifact_lines)
179
-
180
- def extract_fenced_xml_content_from_export(text: str) -> str:
181
- """Extract fenced XML from a canonical message or flattened transcript export."""
182
- normalized = normalize_prompt_workflow_export(text)
183
- return extract_fenced_xml_content(normalized)
184
-
185
- def missing_required_xml_sections(text: str) -> list[str]:
186
- fenced_body = extract_fenced_xml_content(text)
187
- if not fenced_body.strip():
188
- return []
189
- missing_sections: list[str] = []
190
- for section_name in REQUIRED_XML_SECTIONS:
191
- open_tag = re.compile(rf"<{re.escape(section_name)}(\s[^>]*)?>")
192
- close_tag = re.compile(rf"</{re.escape(section_name)}>")
193
- if not open_tag.search(fenced_body) or not close_tag.search(fenced_body):
194
- missing_sections.append(section_name)
195
- return missing_sections
196
-
197
- def _build_negative_keyword_violation(
198
- match: re.Match[str],
199
- line_number: int,
200
- line_text: str,
201
- ) -> dict[str, str | int]:
202
- return {
203
- "keyword": match.group(),
204
- "line_number": line_number,
205
- "line_text": line_text.strip(),
206
- }
207
-
208
- def _find_pattern_violations(
209
- patterns: Iterable[re.Pattern[str]],
210
- line_text: str,
211
- line_number: int,
212
- ) -> list[dict[str, str | int]]:
213
- violations: list[dict[str, str | int]] = []
214
- for pattern in patterns:
215
- match = pattern.search(line_text)
216
- if match:
217
- violations.append(
218
- _build_negative_keyword_violation(match, line_number, line_text),
219
- )
220
- return violations
221
-
222
- def find_negative_keywords_in_fenced_xml(
223
- text: str,
224
- ) -> list[dict[str, str | int]]:
225
- fenced_content = extract_fenced_xml_content(text)
226
- if not fenced_content:
227
- return []
228
- all_violations: list[dict[str, str | int]] = []
229
- for line_index, each_line in enumerate(fenced_content.splitlines(), start=1):
230
- all_violations.extend(
231
- _find_pattern_violations(
232
- COMPILED_NEGATIVE_KEYWORD_PATTERNS,
233
- each_line,
234
- line_index,
235
- ),
236
- )
237
- all_violations.extend(
238
- _find_pattern_violations(
239
- COMPILED_NEGATIVE_INDIRECT_PATTERNS,
240
- each_line,
241
- line_index,
242
- ),
243
- )
244
- return all_violations
245
-
246
- def _contains_any_marker(text: str, markers: Iterable[str]) -> bool:
247
- lower_text = text.lower()
248
- return any(marker.lower() in lower_text for marker in markers)
249
-
250
- def has_debug_intent(text: str) -> bool:
251
- return _contains_any_marker(text, DEBUG_INTENT_MARKERS)
252
-
253
- def has_internal_object_leak(text: str) -> bool:
254
- return _contains_any_marker(text, INTERNAL_OBJECT_MARKERS)
255
-
256
- def missing_scope_anchors(text: str) -> list[str]:
257
- return [anchor for anchor in REQUIRED_SCOPE_ANCHORS if anchor not in text]
258
-
259
- def find_ambiguous_scope_terms(text: str) -> list[str]:
260
- if "scope" not in text.lower():
261
- return []
262
- matches: list[str] = []
263
- lower_text = text.lower()
264
- for term in AMBIGUOUS_SCOPE_TERMS:
265
- if re.search(rf"\b{re.escape(term)}\b", lower_text):
266
- matches.append(term)
267
- return matches
268
-
269
- def has_checklist_container(text: str) -> bool:
270
- lower_text = text.lower()
271
- return "checklist_results" in lower_text or "checklist:" in lower_text
272
-
273
- def missing_checklist_rows(text: str) -> list[str]:
274
- return [row for row in REQUIRED_CHECKLIST_ROWS if row not in text]
275
-
276
- def is_prompt_workflow_response(text: str) -> bool:
277
- lower_text = text.lower()
278
- matched_markers = [
279
- marker for marker in PROMPT_WORKFLOW_RESPONSE_MARKERS if marker in lower_text
280
- ]
281
- return len(matched_markers) >= 2
282
-
283
- def missing_context_control_signals(text: str) -> list[str]:
284
- required_signals: tuple[str, ...] = (
285
- "base_minimal_instruction_layer: true",
286
- "on_demand_skill_loading: true",
287
- )
288
- lowered = text.lower()
289
- return [signal for signal in required_signals if signal not in lowered]
@@ -1,218 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Shared prompt-workflow validator callable from tests, CLI, and the Stop hook.
3
-
4
- Public API
5
- ----------
6
- validate_prompt_workflow(assistant_message, user_context="")
7
- Returns a ``ValidationResult`` with allowed/blocked status and reasons.
8
-
9
- CLI
10
- ---
11
- python prompt_workflow_validate.py path/to/draft.md
12
- cat draft.md | python prompt_workflow_validate.py
13
-
14
- Exit codes match hook conventions: 0 allowed, 2 blocked.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import sys
20
- from dataclasses import dataclass, field
21
- from pathlib import Path
22
-
23
- from prompt_workflow_gate_core import (
24
- find_ambiguous_scope_terms,
25
- find_negative_keywords_in_fenced_xml,
26
- has_checklist_container,
27
- has_debug_intent,
28
- has_internal_object_leak,
29
- is_prompt_workflow_response,
30
- missing_checklist_rows,
31
- missing_context_control_signals,
32
- missing_required_xml_sections,
33
- missing_scope_anchors,
34
- )
35
-
36
- @dataclass(frozen=True)
37
- class ValidationReason:
38
- code: str
39
- message: str
40
-
41
-
42
- @dataclass(frozen=True)
43
- class ValidationResult:
44
- allowed: bool
45
- reasons: tuple[ValidationReason, ...] = field(default_factory=tuple)
46
-
47
- @property
48
- def reason_messages(self) -> list[str]:
49
- return [each_reason.message for each_reason in self.reasons]
50
-
51
- @property
52
- def reason_codes(self) -> list[str]:
53
- return [each_reason.code for each_reason in self.reasons]
54
-
55
-
56
- def _blocked(code: str, message: str) -> ValidationResult:
57
- return ValidationResult(
58
- allowed=False,
59
- reasons=(ValidationReason(code=code, message=message),),
60
- )
61
-
62
-
63
- def _check_internal_leak(
64
- assistant_message: str,
65
- debug_requested: bool,
66
- ) -> ValidationResult | None:
67
- if not has_internal_object_leak(assistant_message) or debug_requested:
68
- return None
69
- return _blocked(
70
- code="internal_object_leak",
71
- message=(
72
- "Raw internal refinement object leakage detected. "
73
- "Return sanitized user-facing output unless explicit debug intent is present."
74
- ),
75
- )
76
-
77
-
78
- def _check_required_sections(assistant_message: str) -> ValidationResult | None:
79
- missing_sections = missing_required_xml_sections(assistant_message)
80
- if not missing_sections:
81
- return None
82
- return _blocked(
83
- code="missing_xml_sections",
84
- message=(
85
- "Fenced XML artifact missing required sections: "
86
- + ", ".join(missing_sections)
87
- ),
88
- )
89
-
90
-
91
- def _check_checklist_rows(assistant_message: str) -> ValidationResult | None:
92
- if not has_checklist_container(assistant_message):
93
- return None
94
- missing_rows = missing_checklist_rows(assistant_message)
95
- if not missing_rows:
96
- return None
97
- return _blocked(
98
- code="missing_checklist_rows",
99
- message=("Deterministic checklist rows missing: " + ", ".join(missing_rows)),
100
- )
101
-
102
-
103
- def _check_scope_anchors(assistant_message: str) -> ValidationResult | None:
104
- missing_anchors = missing_scope_anchors(assistant_message)
105
- if not missing_anchors:
106
- return None
107
- return _blocked(
108
- code="missing_scope_anchors",
109
- message=("Required scope anchors missing: " + ", ".join(missing_anchors)),
110
- )
111
-
112
-
113
- def _check_context_signals(assistant_message: str) -> ValidationResult | None:
114
- missing_signals = missing_context_control_signals(assistant_message)
115
- if not missing_signals:
116
- return None
117
- return _blocked(
118
- code="missing_context_signals",
119
- message=(
120
- "Runtime context-control preamble missing. "
121
- "Include the two required lines from prompt-workflow-context-controls "
122
- "(minimal instruction layer and on-demand skill loading)."
123
- ),
124
- )
125
-
126
-
127
- def _check_ambiguous_scope(assistant_message: str) -> ValidationResult | None:
128
- ambiguous_terms = find_ambiguous_scope_terms(assistant_message)
129
- if not ambiguous_terms:
130
- return None
131
- return _blocked(
132
- code="ambiguous_scope",
133
- message=("Ambiguous scope phrasing detected: " + ", ".join(ambiguous_terms)),
134
- )
135
-
136
-
137
- def _check_negative_keywords(assistant_message: str) -> ValidationResult | None:
138
- violations = find_negative_keywords_in_fenced_xml(assistant_message)
139
- if not violations:
140
- return None
141
- violation_descriptions = [
142
- f" line {each_violation['line_number']}: "
143
- f'"{each_violation["keyword"]}" in: {each_violation["line_text"]}'
144
- for each_violation in violations
145
- ]
146
- return _blocked(
147
- code="negative_keywords_in_artifact",
148
- message=(
149
- "Banned negative keywords found inside fenced XML artifact. "
150
- "Rephrase as positive directives (what TO do, not what to avoid):\n"
151
- + "\n".join(violation_descriptions)
152
- ),
153
- )
154
-
155
-
156
- def validate_prompt_workflow(
157
- assistant_message: str,
158
- user_context: str = "",
159
- ) -> ValidationResult:
160
- """Run all prompt-workflow gates on *assistant_message*.
161
-
162
- Returns ``ValidationResult.allowed == True`` when every gate passes.
163
- The first failing gate short-circuits and its reason is returned.
164
- """
165
- allowed_result = ValidationResult(allowed=True)
166
-
167
- if not assistant_message.strip():
168
- return allowed_result
169
-
170
- debug_requested = has_debug_intent(user_context)
171
-
172
- leak_result = _check_internal_leak(assistant_message, debug_requested)
173
- if leak_result is not None:
174
- return leak_result
175
-
176
- if not is_prompt_workflow_response(assistant_message):
177
- return allowed_result
178
-
179
- workflow_checks = (
180
- _check_required_sections,
181
- _check_checklist_rows,
182
- _check_scope_anchors,
183
- _check_context_signals,
184
- _check_ambiguous_scope,
185
- _check_negative_keywords,
186
- )
187
- for each_check in workflow_checks:
188
- gate_result = each_check(assistant_message)
189
- if gate_result is not None:
190
- return gate_result
191
-
192
- return allowed_result
193
-
194
-
195
- def main() -> None:
196
- blocked_exit_code: int = 2
197
- allowed_exit_code: int = 0
198
-
199
- if len(sys.argv) > 1:
200
- file_path = Path(sys.argv[1])
201
- assistant_text = file_path.read_text(encoding="utf-8")
202
- elif not sys.stdin.isatty():
203
- assistant_text = sys.stdin.read()
204
- else:
205
- sys.stderr.write("Usage: prompt_workflow_validate.py [path/to/draft.md]\n")
206
- sys.stderr.write(" cat draft.md | prompt_workflow_validate.py\n")
207
- sys.exit(blocked_exit_code)
208
-
209
- validation_result = validate_prompt_workflow(assistant_text)
210
- if validation_result.allowed:
211
- sys.exit(allowed_exit_code)
212
- for each_reason in validation_result.reasons:
213
- sys.stderr.write(f"[{each_reason.code}] {each_reason.message}\n")
214
- sys.exit(blocked_exit_code)
215
-
216
-
217
- if __name__ == "__main__":
218
- main()
@@ -1,54 +0,0 @@
1
- """Tests for prompt_workflow_clipboard."""
2
-
3
- from __future__ import annotations
4
-
5
- import pytest
6
-
7
- from prompt_workflow_clipboard import copy_text_to_system_clipboard
8
-
9
-
10
- def test_empty_text_returns_false() -> None:
11
- assert copy_text_to_system_clipboard("") is False
12
- assert copy_text_to_system_clipboard(" \n") is False
13
-
14
-
15
- def test_respects_skip_env(monkeypatch: pytest.MonkeyPatch) -> None:
16
- monkeypatch.setenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", "1")
17
- assert copy_text_to_system_clipboard("hello") is False
18
-
19
-
20
- @pytest.mark.parametrize(
21
- "flag_value",
22
- ("1", "true", "YES", "on"),
23
- )
24
- def test_skip_env_variants(monkeypatch: pytest.MonkeyPatch, flag_value: str) -> None:
25
- monkeypatch.setenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", flag_value)
26
- assert copy_text_to_system_clipboard("payload") is False
27
-
28
-
29
- def test_prefers_tkinter_when_it_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
30
- monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
31
- monkeypatch.setattr(
32
- "prompt_workflow_clipboard._copy_via_tkinter",
33
- lambda t: True,
34
- )
35
-
36
- def _fail_pyperclip(_t: str) -> bool:
37
- raise AssertionError("pyperclip must run only when tkinter fails")
38
-
39
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", _fail_pyperclip)
40
- assert copy_text_to_system_clipboard("hello") is True
41
-
42
-
43
- def test_falls_back_to_pyperclip(monkeypatch: pytest.MonkeyPatch) -> None:
44
- monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
45
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_tkinter", lambda t: False)
46
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", lambda t: True)
47
- assert copy_text_to_system_clipboard("hello") is True
48
-
49
-
50
- def test_returns_false_when_both_backends_fail(monkeypatch: pytest.MonkeyPatch) -> None:
51
- monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
52
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_tkinter", lambda t: False)
53
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", lambda t: False)
54
- assert copy_text_to_system_clipboard("hello") is False