@1aboveio/skills 1.0.15 → 1.0.16

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 (90) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +149 -145
  3. package/package.json +4 -4
  4. package/skills/app-dev/airflow/SKILL.md +1 -1
  5. package/skills/app-dev/android-development/SKILL.md +28 -1
  6. package/skills/app-dev/android-development/references/diagnosis.md +50 -0
  7. package/skills/app-dev/android-development/references/init.md +31 -2
  8. package/skills/app-dev/android-development/references/setup.md +8 -2
  9. package/skills/app-dev/android-development/references/ui-journeys.md +58 -0
  10. package/skills/app-dev/android-development/scripts/device-run.sh +93 -2
  11. package/skills/app-dev/android-development/scripts/failure_capture.py +331 -0
  12. package/skills/app-dev/android-development/scripts/init.mjs +129 -9
  13. package/skills/app-dev/android-development/scripts/maestro_failure.py +401 -0
  14. package/skills/app-dev/android-development/scripts/test_failure_capture.py +378 -0
  15. package/skills/app-dev/android-development/scripts/test_maestro_failure.py +462 -0
  16. package/skills/app-dev/app-debug/SKILL.md +1 -1
  17. package/skills/app-dev/better-auth/SKILL.md +1 -1
  18. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  19. package/skills/app-dev/data-quality/SKILL.md +1 -1
  20. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  21. package/skills/app-dev/monorepo/SKILL.md +1 -1
  22. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  23. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  24. package/skills/app-dev/pyspark/SKILL.md +1 -1
  25. package/skills/app-dev/python-backend/SKILL.md +1 -1
  26. package/skills/app-dev/shadcn/SKILL.md +1 -1
  27. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  28. package/skills/cicd/cloud-build/SKILL.md +1 -1
  29. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  30. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  31. package/skills/cicd/docker/SKILL.md +1 -1
  32. package/skills/cicd/github-actions/SKILL.md +1 -1
  33. package/skills/cicd/github-flow/SKILL.md +1 -1
  34. package/skills/cicd/google-cloud/SKILL.md +1 -1
  35. package/skills/cicd/mergify/SKILL.md +1 -1
  36. package/skills/cicd/podman/SKILL.md +1 -1
  37. package/skills/content/article-review/SKILL.md +1 -1
  38. package/skills/content/article-to-image/SKILL.md +1 -1
  39. package/skills/content/flowchart-svg/SKILL.md +1 -1
  40. package/skills/content/image-gen/SKILL.md +1 -1
  41. package/skills/content/mermaid/SKILL.md +1 -1
  42. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  43. package/skills/content/wechat-publisher/SKILL.md +1 -1
  44. package/skills/content/wechat-search/SKILL.md +1 -1
  45. package/skills/content/xai-search/SKILL.md +1 -1
  46. package/skills/core/distill-writing/SKILL.md +1 -1
  47. package/skills/core/doc-to-md/SKILL.md +1 -1
  48. package/skills/core/editorial-review/SKILL.md +1 -1
  49. package/skills/core/house-style/SKILL.md +1 -1
  50. package/skills/core/plain-language/SKILL.md +1 -1
  51. package/skills/core/polish-cn/SKILL.md +1 -1
  52. package/skills/core/reg-parser/SKILL.md +1 -1
  53. package/skills/core/terminology/SKILL.md +1 -1
  54. package/skills/core/write-doc/SKILL.md +1 -1
  55. package/skills/core/write-report/SKILL.md +1 -1
  56. package/skills/engineering/better-review/SKILL.md +1 -1
  57. package/skills/engineering/e2e-test/SKILL.md +1 -1
  58. package/skills/engineering/engineering/SKILL.md +1 -1
  59. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  60. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  61. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  62. package/skills/engineering/golden-path/SKILL.md +1 -1
  63. package/skills/engineering/harness-runtime/SKILL.md +4 -3
  64. package/skills/engineering/harness-runtime/agent-catalog.mjs +928 -0
  65. package/skills/engineering/harness-runtime/contracts.md +2 -0
  66. package/skills/engineering/harness-runtime/references/model-catalog.md +7 -1
  67. package/skills/engineering/resolve-issues/SKILL.md +9 -6
  68. package/skills/engineering/resolve-issues/references/delivery-progress.md +2 -0
  69. package/skills/engineering/resolve-issues/references/expire.md +6 -3
  70. package/skills/engineering/resolve-issues/references/implementation.md +6 -1
  71. package/skills/engineering/resolve-issues/references/preflight.md +26 -17
  72. package/skills/engineering/resolve-issues/references/review.md +21 -9
  73. package/skills/engineering/resolve-issues/references/shared-modules.md +88 -20
  74. package/skills/engineering/resolve-issues/references/task-plan.md +35 -1
  75. package/skills/engineering/resolve-issues/scripts/preflight-models.mjs +382 -592
  76. package/skills/engineering/resolve-release/SKILL.md +1 -1
  77. package/skills/engineering/rush-issues/SKILL.md +4 -4
  78. package/skills/engineering/rush-release/SKILL.md +1 -1
  79. package/skills/engineering/test-quality/SKILL.md +1 -1
  80. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  81. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  82. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  83. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  84. package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
  85. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  86. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
  87. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  88. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
  89. package/skills/engineering/resolve-issues/scripts/discover-models.mjs +0 -9
  90. package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +0 -9
@@ -0,0 +1,331 @@
1
+ """Record the evidence of a failed Android device case.
2
+
3
+ Journey code wraps the steps it drives:
4
+
5
+ from failure_capture import FailureRecorder
6
+
7
+ recorder = FailureRecorder(case="checkout smoke", evidence_dir=os.environ["LOG_DIR"])
8
+
9
+ with recorder.step("tap Pay"):
10
+ tap(...)
11
+ wait_for("Thank you")
12
+
13
+ The first step exception captures the screen, the UI hierarchy and a bounded logcat
14
+ tail into the evidence directory, writes ``failure.json``, prints exactly one
15
+ ``NATIVE_FAILURE {...}`` line and lets the original exception propagate unchanged.
16
+ ``recorder.step`` is a context manager and a decorator, so a plain callable step can
17
+ be wrapped the same way.
18
+
19
+ Record shape, shared with the ``device-run.sh`` fallback and with the framework
20
+ adapters described in ``references/diagnosis.md``:
21
+
22
+ case, step, error, screenshot, uiDump, logcatTail, evidenceDir, cleanupErrors
23
+
24
+ ``screenshot``, ``uiDump`` and ``logcatTail`` are file names relative to
25
+ ``evidenceDir`` and are ``null`` when that capture failed; the reason goes to
26
+ ``cleanupErrors`` and never replaces the original ``error``. A record is written
27
+ once: the first failure is kept, and a later failure or a cleanup problem never
28
+ overwrites it.
29
+
30
+ Other languages implement the same contract. A repo that already uses Maestro or
31
+ Detox maps that framework's failing-step artifacts to this record instead of adding
32
+ this helper.
33
+
34
+ If the evidence directory cannot be written, the reason goes to stderr and the
35
+ ``NATIVE_FAILURE`` line is still printed with its path; the original exception
36
+ always propagates unchanged.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import contextlib
42
+ import json
43
+ import os
44
+ import subprocess
45
+ import sys
46
+ import tempfile
47
+ from dataclasses import dataclass, field
48
+
49
+ RECORD_NAME = "failure.json"
50
+ NATIVE_FAILURE_PREFIX = "NATIVE_FAILURE "
51
+ SCREENSHOT_NAME = "screenshot.png"
52
+ UI_DUMP_NAME = "ui-dump.xml"
53
+ LOGCAT_NAME = "logcat-tail.txt"
54
+ UI_DUMP_REMOTE = "/sdcard/failure-capture.xml"
55
+
56
+ RECORD_KEYS = (
57
+ "case",
58
+ "step",
59
+ "error",
60
+ "screenshot",
61
+ "uiDump",
62
+ "logcatTail",
63
+ "evidenceDir",
64
+ "cleanupErrors",
65
+ )
66
+ NATIVE_FAILURE_KEYS = ("case", "step", "error", "evidenceDir")
67
+
68
+
69
+ class CaptureError(RuntimeError):
70
+ """A bounded capture command failed, timed out or returned no usable output."""
71
+
72
+
73
+ def describe_error(error: BaseException) -> str:
74
+ """A one-value error summary; JSON encoding keeps the line single-line."""
75
+ return "%s: %s" % (type(error).__name__, error)
76
+
77
+
78
+ def _write_bytes(path: str, payload: bytes) -> None:
79
+ """Replace `path` atomically so a partial capture is never read as evidence."""
80
+ directory = os.path.dirname(path) or "."
81
+ handle, temporary = tempfile.mkstemp(dir=directory, prefix=".capture-", suffix=".part")
82
+ try:
83
+ with os.fdopen(handle, "wb") as stream:
84
+ stream.write(payload)
85
+ os.replace(temporary, path)
86
+ except BaseException:
87
+ if os.path.exists(temporary):
88
+ os.unlink(temporary)
89
+ raise
90
+
91
+
92
+ def _text(value) -> str:
93
+ if value is None:
94
+ return ""
95
+ if isinstance(value, bytes):
96
+ return value.decode("utf-8", "replace").strip()
97
+ return str(value).strip()
98
+
99
+
100
+ class AdbClient:
101
+ """Bounded adb captures for one device.
102
+
103
+ `serial` selects the device with `-s`, so the caller's private ADB endpoint and
104
+ device mapping stay authoritative. `adb` is the injectable runner: it receives
105
+ the full argv and the per-command timeout and must return an object with
106
+ `returncode`, `stdout` and `stderr` (like `subprocess.CompletedProcess`). Tests
107
+ pass a fake here; production uses `subprocess.run`.
108
+ """
109
+
110
+ def __init__(self, serial=None, adb=None, executable="adb"):
111
+ self.serial = serial
112
+ self.executable = executable
113
+ self._adb = adb if adb is not None else self._run_subprocess
114
+
115
+ @staticmethod
116
+ def _run_subprocess(command, timeout):
117
+ return subprocess.run(command, capture_output=True, timeout=timeout, check=False)
118
+
119
+ def command(self, *args):
120
+ serial_args = ("-s", str(self.serial)) if self.serial else ()
121
+ return [self.executable, *serial_args, *args]
122
+
123
+ def _call(self, *args, timeout):
124
+ label = "adb %s" % " ".join(str(arg) for arg in args)
125
+ try:
126
+ result = self._adb(self.command(*args), timeout)
127
+ except subprocess.TimeoutExpired as error:
128
+ raise CaptureError("%s timed out after %ss" % (label, timeout)) from error
129
+ except OSError as error:
130
+ raise CaptureError("%s could not run: %s" % (label, error)) from error
131
+ status = getattr(result, "returncode", 0)
132
+ if status != 0:
133
+ detail = _text(getattr(result, "stderr", None))
134
+ raise CaptureError("%s exited %s%s" % (label, status, ": " + detail if detail else ""))
135
+ return getattr(result, "stdout", b"") or b""
136
+
137
+ def screencap(self, destination, timeout):
138
+ payload = self._call("exec-out", "screencap", "-p", timeout=timeout)
139
+ if not payload:
140
+ raise CaptureError("adb exec-out screencap -p returned no image")
141
+ _write_bytes(destination, payload)
142
+
143
+ def ui_dump(self, destination, timeout):
144
+ self._call("shell", "uiautomator", "dump", UI_DUMP_REMOTE, timeout=timeout)
145
+ payload = self._call("exec-out", "cat", UI_DUMP_REMOTE, timeout=timeout)
146
+ if b"<hierarchy" not in payload:
147
+ raise CaptureError("uiautomator dump returned no hierarchy")
148
+ _write_bytes(destination, payload)
149
+
150
+ def logcat_tail(self, destination, lines, timeout):
151
+ payload = self._call("logcat", "-d", "-t", str(lines), timeout=timeout)
152
+ _write_bytes(destination, payload)
153
+
154
+
155
+ @dataclass
156
+ class Failure:
157
+ """One failed device case; also the payload of `failure.json`."""
158
+
159
+ case: str = None
160
+ step: str = None
161
+ error: str = ""
162
+ screenshot: str = None
163
+ uiDump: str = None
164
+ logcatTail: str = None
165
+ evidenceDir: str = None
166
+ cleanupErrors: list = field(default_factory=list)
167
+
168
+ def as_dict(self):
169
+ return {key: getattr(self, key) for key in RECORD_KEYS}
170
+
171
+ def native_failure_line(self):
172
+ compact = {key: getattr(self, key) for key in NATIVE_FAILURE_KEYS}
173
+ return NATIVE_FAILURE_PREFIX + json.dumps(compact, separators=(",", ":"))
174
+
175
+ @classmethod
176
+ def from_dict(cls, payload):
177
+ values = {key: payload[key] for key in RECORD_KEYS if key in payload}
178
+ errors = values.get("cleanupErrors")
179
+ values["cleanupErrors"] = list(errors) if isinstance(errors, list) else []
180
+ return cls(**values)
181
+
182
+
183
+ def read_record(evidence_dir):
184
+ """The record already in `evidence_dir`, or None when there is no usable one."""
185
+ path = os.path.join(evidence_dir, RECORD_NAME)
186
+ try:
187
+ with open(path, "r", encoding="utf-8") as stream:
188
+ payload = json.load(stream)
189
+ except (OSError, ValueError):
190
+ return None
191
+ if not isinstance(payload, dict) or "error" not in payload:
192
+ return None
193
+ return Failure.from_dict(payload)
194
+
195
+
196
+ def write_record(failure, evidence_dir):
197
+ """Store `failure` unless a record already exists; return the record on disk.
198
+
199
+ A valid earlier record wins, so a cleanup error or a second failing step cannot
200
+ replace the first failure. An unreadable file is not a record and is replaced,
201
+ with the replacement noted on the returned record's `cleanupErrors`.
202
+ """
203
+ os.makedirs(evidence_dir, exist_ok=True)
204
+ existing = read_record(evidence_dir)
205
+ if existing is not None:
206
+ return existing
207
+ path = os.path.join(evidence_dir, RECORD_NAME)
208
+ if os.path.exists(path):
209
+ failure.cleanupErrors.append("%s: replaced an unreadable record" % RECORD_NAME)
210
+ payload = json.dumps(failure.as_dict(), indent=2) + "\n"
211
+ handle, temporary = tempfile.mkstemp(dir=evidence_dir, prefix=".failure-", suffix=".json")
212
+ try:
213
+ with os.fdopen(handle, "w", encoding="utf-8") as stream:
214
+ stream.write(payload)
215
+ os.replace(temporary, path)
216
+ except BaseException:
217
+ if os.path.exists(temporary):
218
+ os.unlink(temporary)
219
+ raise
220
+ return failure
221
+
222
+
223
+ class FailureRecorder:
224
+ """Capture the first failing step of one device case.
225
+
226
+ `case` names the case; it defaults to `DEVICE_CASE`. `evidence_dir` defaults to
227
+ `LOG_DIR` so the record sits where the launcher and the harness already look.
228
+ Capture commands are bounded by `capture_timeout` each; a capture problem is
229
+ recorded on `cleanupErrors` and never replaces the original error.
230
+ """
231
+
232
+ def __init__(
233
+ self,
234
+ case=None,
235
+ evidence_dir=None,
236
+ *,
237
+ client=None,
238
+ serial=None,
239
+ adb=None,
240
+ logcat_lines=400,
241
+ capture_timeout=20.0,
242
+ stream=None,
243
+ ):
244
+ self.case = case if case is not None else os.environ.get("DEVICE_CASE")
245
+ self.evidence_dir = os.path.abspath(
246
+ evidence_dir or os.environ.get("LOG_DIR") or os.path.join(".cache", "logs")
247
+ )
248
+ self.client = client if client is not None else AdbClient(serial=serial, adb=adb)
249
+ self.logcat_lines = logcat_lines
250
+ self.capture_timeout = capture_timeout
251
+ self.stream = stream if stream is not None else sys.stdout
252
+ self.failure = None
253
+ self._recorded = False
254
+
255
+ def step(self, name):
256
+ return _Step(self, name)
257
+
258
+ def _captures(self):
259
+ timeout = self.capture_timeout
260
+ return (
261
+ ("screenshot", SCREENSHOT_NAME, lambda path: self.client.screencap(path, timeout)),
262
+ ("uiDump", UI_DUMP_NAME, lambda path: self.client.ui_dump(path, timeout)),
263
+ (
264
+ "logcatTail",
265
+ LOGCAT_NAME,
266
+ lambda path: self.client.logcat_tail(path, self.logcat_lines, timeout),
267
+ ),
268
+ )
269
+
270
+ def record(self, step, error):
271
+ """Capture evidence for `error` at `step`, once per recorder, then keep going."""
272
+ if self._recorded:
273
+ return self.failure
274
+ self._recorded = True
275
+ try:
276
+ directory_problem = None
277
+ os.makedirs(self.evidence_dir, exist_ok=True)
278
+ except OSError as problem:
279
+ directory_problem = problem
280
+ # An earlier record owns this directory: its evidence files are not replaced
281
+ # either, so the first failure stays readable.
282
+ kept = read_record(self.evidence_dir)
283
+ if kept is not None:
284
+ self.failure = kept
285
+ print(kept.native_failure_line(), file=self.stream, flush=True)
286
+ return kept
287
+ failure = Failure(
288
+ case=self.case,
289
+ step=step,
290
+ error=error,
291
+ evidenceDir=self.evidence_dir,
292
+ )
293
+ if directory_problem is not None:
294
+ failure.cleanupErrors.append("evidence dir %s: %s" % (self.evidence_dir, directory_problem))
295
+ for attribute, name, capture in self._captures():
296
+ try:
297
+ capture(os.path.join(self.evidence_dir, name))
298
+ except (CaptureError, OSError) as problem:
299
+ failure.cleanupErrors.append("%s: %s" % (attribute, problem))
300
+ else:
301
+ setattr(failure, attribute, name)
302
+ try:
303
+ failure = write_record(failure, self.evidence_dir)
304
+ except OSError as problem:
305
+ print(
306
+ "failure record not written: %s" % problem,
307
+ file=sys.stderr,
308
+ )
309
+ self.failure = failure
310
+ print(failure.native_failure_line(), file=self.stream, flush=True)
311
+ return failure
312
+
313
+
314
+ class _Step(contextlib.ContextDecorator):
315
+ """A named journey step: capture on the first exception, then re-raise."""
316
+
317
+ def __init__(self, recorder, name):
318
+ self.recorder = recorder
319
+ self.name = name
320
+
321
+ def __enter__(self):
322
+ return self
323
+
324
+ def __exit__(self, error_type, error, traceback):
325
+ if error is None or error_type is None:
326
+ return False
327
+ # An interrupted or exited run is a cancellation, not a failed case.
328
+ if issubclass(error_type, (KeyboardInterrupt, SystemExit)):
329
+ return False
330
+ self.recorder.record(self.name, describe_error(error))
331
+ return False
@@ -1,4 +1,5 @@
1
1
  // Explicit Android project setup. Preview by default; adds only missing guidance.
2
+ import { createHash } from 'node:crypto'
2
3
  import fs from 'node:fs'
3
4
  import os from 'node:os'
4
5
  import path from 'node:path'
@@ -15,15 +16,29 @@ read and apply the installed \`android-development\` skill before choosing
15
16
  Android-specific commands. In issue workflows, load it during unit preflight.
16
17
  Delegated Android workers must load it too. After compaction, reload it if its
17
18
  instructions are no longer in context.`
18
- const usage = 'node init.mjs [--repo-root <dir>] [--write]'
19
+ const usage = 'node init.mjs [--repo-root <dir>] [--install-scripts [<dir>]] [--write]'
20
+ // The generic layer ships in the skill and is referenced from there by default.
21
+ const defaultScriptsDir = 'scripts/android'
22
+ const installableScripts = [
23
+ { name: 'device-run.sh', mode: 0o755 },
24
+ { name: 'failure_capture.py', mode: 0o644 },
25
+ ]
26
+ const scriptHeaderPattern = /^# android-development skill (\S+) sha256:([0-9a-f]{64}) (\S+)$/
19
27
 
20
28
  function parseArgs(argv) {
21
- const options = { write: false }
29
+ const options = { write: false, scripts: null }
22
30
  for (let i = 0; i < argv.length; i++) {
23
31
  const arg = argv[i]
24
32
  if (arg === '--write') options.write = true
25
33
  else if (arg === '--help') options.help = true
26
- else if (arg === '--repo-root') {
34
+ else if (arg === '--install-scripts') {
35
+ // The optional target directory never starts with `--`; the flag alone copies
36
+ // the generic layer to its default location.
37
+ const value = argv[i + 1]
38
+ const explicit = value !== undefined && !value.startsWith('--')
39
+ options.scripts = explicit ? value : ''
40
+ if (explicit) i++
41
+ } else if (arg === '--repo-root') {
27
42
  const value = argv[++i]
28
43
  if (!value || value.startsWith('--') || /[\r\n]/.test(value)) throw new Error(`missing or invalid value for ${arg}`)
29
44
  options.repoRoot = path.resolve(value)
@@ -392,7 +407,7 @@ function addTaskEntry(current, name) {
392
407
  return append(current, `${start}\n${taskEntryBody}\n${end}`, name)
393
408
  }
394
409
 
395
- function planInitialization(root) {
410
+ function planInitialization(root, options = { scripts: null }) {
396
411
  const claude = inspectClaude(root)
397
412
  const [agents, contributing] = ['AGENTS.md', 'CONTRIBUTING.md'].map(name => resolveTarget(root, name))
398
413
  if (agents.target === contributing.target) {
@@ -424,6 +439,7 @@ function planInitialization(root) {
424
439
  const plan = [
425
440
  { ...agentsEntry, next: agentText },
426
441
  { ...contributingEntry, next: contributingText },
442
+ ...scriptPlan(root, options.scripts),
427
443
  ]
428
444
 
429
445
  for (const entry of plan) {
@@ -439,10 +455,103 @@ function planInitialization(root) {
439
455
  return plan
440
456
  }
441
457
 
458
+ function skillVersion() {
459
+ const text = fs.readFileSync(fileURLToPath(new URL('../SKILL.md', import.meta.url)), 'utf8')
460
+ const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? ''
461
+ return /^release-version:\s*(\S+)\s*$/m.exec(frontmatter)?.[1] ?? 'unknown'
462
+ }
463
+
464
+ function scriptSource(name) {
465
+ return fs.readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), 'utf8')
466
+ }
467
+
468
+ function scriptHeader(name, version, source) {
469
+ return `# android-development skill ${version} sha256:${createHash('sha256').update(source).digest('hex')} ${name}`
470
+ }
471
+
472
+ // A copy is identifiable by its header, and its body stays byte-identical to the
473
+ // shipped script, so an unmodified copy is recognisable and a hand edit is drift.
474
+ // The header follows a shebang or a module docstring, so neither stops being first.
475
+ function withHeader(source, header) {
476
+ const lines = source.split('\n')
477
+ let index = 0
478
+ if (lines[0].startsWith('#!')) index = 1
479
+ else if (/^("""|''')/.test(lines[0])) {
480
+ const quote = lines[0].slice(0, 3)
481
+ const closing = lines[0].length > 3 && lines[0].trimEnd().endsWith(quote)
482
+ ? 0
483
+ : lines.findIndex((line, position) => position > 0 && line.trimEnd().endsWith(quote))
484
+ if (closing >= 0) index = closing + 1
485
+ }
486
+ return [...lines.slice(0, index), header, ...lines.slice(index)].join('\n')
487
+ }
488
+
489
+ function splitHeader(text) {
490
+ const lines = text.split('\n')
491
+ // Only the preamble can hold the installed header; the body's own docs cannot.
492
+ const limit = Math.min(lines.length, 40)
493
+ for (let index = 0; index < limit; index++) {
494
+ if (scriptHeaderPattern.test(lines[index])) {
495
+ return { header: lines[index], body: [...lines.slice(0, index), ...lines.slice(index + 1)].join('\n') }
496
+ }
497
+ }
498
+ return { header: null, body: text }
499
+ }
500
+
501
+ function scriptsTarget(value) {
502
+ if (value === null || value === undefined) return null
503
+ const relative = (value || defaultScriptsDir).trim()
504
+ const segments = relative.split(/[\\/]+/).filter(segment => segment && segment !== '.')
505
+ if (!segments.length || path.isAbsolute(relative)
506
+ || segments.includes('..') || /[\r\n]/.test(relative)) {
507
+ throw new Error(`--install-scripts target must be a relative directory inside the project: ${relative}`)
508
+ }
509
+ return segments.join('/')
510
+ }
511
+
512
+ function scriptPlan(root, requested) {
513
+ const target = scriptsTarget(requested)
514
+ if (target === null) return []
515
+ const version = skillVersion()
516
+ return installableScripts.map(script => {
517
+ const file = path.join(root, target, script.name)
518
+ assertScriptDirectory(root, file)
519
+ const source = scriptSource(script.name)
520
+ const next = withHeader(source, scriptHeader(script.name, version, source))
521
+ const entry = readGuidance(resolveTarget(root, path.posix.join(target, script.name)))
522
+ const base = { ...entry, mode: script.mode, script: script.name }
523
+ if (!entry.exists) return { ...base, next, action: 'create' }
524
+ if (entry.current === next) return { ...base, next, action: 'unchanged' }
525
+ if (splitHeader(entry.current).body === source) return { ...base, next, action: 'update' }
526
+ // A local edit wins: report it, never overwrite it, and do not claim the skill version.
527
+ return { ...base, next: entry.current, action: 'drift' }
528
+ })
529
+ }
530
+
531
+ // The copy's directory is created, so only its nearest existing ancestor can be checked:
532
+ // it must be a directory that stays inside the selected project.
533
+ function assertScriptDirectory(root, file) {
534
+ const directory = nearestExisting(path.dirname(file))
535
+ const resolved = fs.lstatSync(directory).isSymbolicLink() ? fs.realpathSync(directory) : directory
536
+ if (!contains(root, resolved) || !fs.statSync(resolved).isDirectory()) {
537
+ throw new Error(`${path.relative(root, path.dirname(file))} is not a directory inside the project; no scripts were copied`)
538
+ }
539
+ }
540
+
541
+ function nearestExisting(target) {
542
+ let current = target
543
+ while (!fs.existsSync(current)) {
544
+ const parent = path.dirname(current)
545
+ if (parent === current) return current
546
+ current = parent
547
+ }
548
+ return current
549
+ }
550
+
442
551
  function assertWritable(plan) {
443
552
  for (const entry of plan) {
444
553
  if (entry.current === entry.next) continue
445
- const target = entry.current === null ? path.dirname(entry.target) : entry.target
554
+ const target = entry.current === null ? nearestExisting(path.dirname(entry.target)) : entry.target
446
555
  try {
447
556
  fs.accessSync(target, fs.constants.W_OK)
448
557
  } catch {
@@ -458,13 +567,24 @@ function main(argv) {
458
567
  return
459
568
  }
460
569
  const { root } = selectRoot(options)
461
- const plan = planInitialization(root)
570
+ const plan = planInitialization(root, options)
462
571
  if (options.write) assertWritable(plan)
463
572
  console.log(`android-development init: ${options.write ? 'write' : 'preview only'} (${root})`)
464
573
  for (const entry of plan) {
465
- const action = entry.current === entry.next ? 'unchanged' : entry.current === null ? 'create' : 'update'
466
- if (options.write && action !== 'unchanged') fs.writeFileSync(entry.target, entry.next)
467
- console.log(` ${entry.name}: ${!options.write && action !== 'unchanged' ? 'would ' : ''}${action}`)
574
+ const action = entry.action ?? (entry.current === entry.next ? 'unchanged' : entry.current === null ? 'create' : 'update')
575
+ const planned = action === 'create' || action === 'update'
576
+ if (options.write && planned) {
577
+ fs.mkdirSync(path.dirname(entry.target), { recursive: true })
578
+ fs.writeFileSync(entry.target, entry.next)
579
+ if (entry.mode) fs.chmodSync(entry.target, entry.mode)
580
+ }
581
+ const label = !options.write && planned ? `would ${action}` : action
582
+ console.log(` ${entry.name}: ${label}`)
583
+ }
584
+ if (options.scripts === null) {
585
+ console.log(` scripts: referenced from the skill (pass --install-scripts [<dir>] to copy ${installableScripts.map(script => script.name).join(' and ')} into ${defaultScriptsDir})`)
586
+ } else if (plan.some(entry => entry.action === 'drift')) {
587
+ console.log(' scripts: a locally modified copy was kept; refresh it by hand rather than overwriting the edit')
468
588
  }
469
589
  console.log('Existing sections are preserved; initialization does not refresh project policy, install tools, provision devices or authorize delivery actions.')
470
590
  }