@nomadamas/k-skill 0.2.0 → 0.2.1

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/bin/k-skill.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/k-skill",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "k-skill unified CLI: assembles runtime-aware skill instructions and ships bundled helper files",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -36,7 +36,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
36
36
  스크립트 helper 도 같은 report endpoint 를 기본 경로로 사용한다.
37
37
 
38
38
  ```bash
39
- python3 scripts/fine_dust.py report --region-hint '서울 강남구' --json
39
+ npx -y @nomadamas/k-skill@0 exec fine-dust-location scripts/fine_dust.py -- report --region-hint '서울 강남구' --json
40
40
  ```
41
41
 
42
42
  ## Ambiguous locations
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import sys
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from math import atan2, cos, radians, sin, sqrt, tan
13
+
14
+ STATION_SERVICE_URL = "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc"
15
+ MEASUREMENT_SERVICE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc"
16
+ SECRET_NAME = "AIR_KOREA_OPEN_API_KEY"
17
+ PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
18
+ DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
19
+ PROXY_DOWN_MSG = "설정된 k-skill-proxy 서버가 응답하지 않습니다. 잠시 후 재시도하거나 운영자에게 문의하세요."
20
+ PROXY_KEY_NOT_CONFIGURED_MSG = "k-skill-proxy에 필요한 API 키가 설정되어 있지 않습니다. 운영자에게 문의하세요."
21
+ WGS84_A = 6378137.0
22
+ WGS84_F = 1 / 298.257223563
23
+ BESSEL_A = 6377397.155
24
+ BESSEL_F = 1 / 299.1528128
25
+ AIR_KOREA_TM_LAT0 = radians(38.0)
26
+ AIR_KOREA_TM_LON0 = radians(127.0)
27
+ AIR_KOREA_TM_FALSE_EASTING = 200000.0
28
+ AIR_KOREA_TM_FALSE_NORTHING = 500000.0
29
+ AIR_KOREA_TM_SCALE = 1.0
30
+ AIR_KOREA_WGS84_TO_BESSEL = (146.43, -507.89, -681.46)
31
+ GRADE_LABELS = {
32
+ "1": "좋음",
33
+ "2": "보통",
34
+ "3": "나쁨",
35
+ "4": "매우나쁨",
36
+ }
37
+
38
+
39
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
40
+ parser = argparse.ArgumentParser(
41
+ description="Summarize Air Korea PM10/PM2.5 data from location or fallback hints.",
42
+ )
43
+ subparsers = parser.add_subparsers(dest="command", required=True)
44
+
45
+ report = subparsers.add_parser("report", help="build a PM10/PM2.5 report")
46
+ report.add_argument("--lat", type=float, help="WGS84 latitude")
47
+ report.add_argument("--lon", type=float, help="WGS84 longitude")
48
+ report.add_argument("--region-hint", help="fallback region/administrative-area hint")
49
+ report.add_argument("--station-name", help="explicit station name fallback")
50
+ report.add_argument("--station-file", help="offline station JSON fixture")
51
+ report.add_argument("--measurement-file", help="offline measurement JSON fixture")
52
+ report.add_argument("--json", action="store_true", help="print JSON instead of text")
53
+ return parser.parse_args(argv)
54
+
55
+
56
+ def load_json_file(path: str | os.PathLike[str]) -> dict:
57
+ return json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
58
+
59
+
60
+ def extract_items(payload: dict | list) -> list[dict]:
61
+ if isinstance(payload, list):
62
+ return payload
63
+
64
+ response = payload.get("response", {})
65
+ body = response.get("body", {})
66
+ items = body.get("items", [])
67
+
68
+ if isinstance(items, dict):
69
+ return [items]
70
+ if isinstance(items, list):
71
+ return items
72
+ return []
73
+
74
+
75
+ def to_float(raw: object) -> float | None:
76
+ if raw in (None, "", "-"):
77
+ return None
78
+ try:
79
+ return float(str(raw))
80
+ except ValueError:
81
+ return None
82
+
83
+
84
+ def squared_distance(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float:
85
+ return (lat_a - lat_b) ** 2 + (lon_a - lon_b) ** 2
86
+
87
+
88
+ def meridional_arc(phi: float, *, semi_major_axis: float, eccentricity_squared: float) -> float:
89
+ e2 = eccentricity_squared
90
+ return semi_major_axis * (
91
+ (1 - e2 / 4 - 3 * e2**2 / 64 - 5 * e2**3 / 256) * phi
92
+ - (3 * e2 / 8 + 3 * e2**2 / 32 + 45 * e2**3 / 1024) * sin(2 * phi)
93
+ + (15 * e2**2 / 256 + 45 * e2**3 / 1024) * sin(4 * phi)
94
+ - (35 * e2**3 / 3072) * sin(6 * phi)
95
+ )
96
+
97
+
98
+ def wgs84_to_bessel(lat: float, lon: float) -> tuple[float, float]:
99
+ dx, dy, dz = AIR_KOREA_WGS84_TO_BESSEL
100
+ source_e2 = 2 * WGS84_F - WGS84_F**2
101
+ target_e2 = 2 * BESSEL_F - BESSEL_F**2
102
+
103
+ lat_rad = radians(lat)
104
+ lon_rad = radians(lon)
105
+ sin_lat = sin(lat_rad)
106
+ cos_lat = cos(lat_rad)
107
+ prime_vertical_radius = WGS84_A / sqrt(1 - source_e2 * sin_lat * sin_lat)
108
+
109
+ x = prime_vertical_radius * cos_lat * cos(lon_rad) + dx
110
+ y = prime_vertical_radius * cos_lat * sin(lon_rad) + dy
111
+ z = prime_vertical_radius * (1 - source_e2) * sin_lat + dz
112
+
113
+ lon_bessel = atan2(y, x)
114
+ horizontal = sqrt(x * x + y * y)
115
+ lat_bessel = atan2(z, horizontal * (1 - target_e2))
116
+
117
+ for _ in range(8):
118
+ sin_lat_bessel = sin(lat_bessel)
119
+ bessel_radius = BESSEL_A / sqrt(1 - target_e2 * sin_lat_bessel * sin_lat_bessel)
120
+ next_lat = atan2(z + target_e2 * bessel_radius * sin_lat_bessel, horizontal)
121
+ if abs(next_lat - lat_bessel) < 1e-14:
122
+ lat_bessel = next_lat
123
+ break
124
+ lat_bessel = next_lat
125
+
126
+ return lat_bessel, lon_bessel
127
+
128
+
129
+ def wgs84_to_air_korea_tm(lat: float, lon: float) -> tuple[float, float]:
130
+ lat_rad, lon_rad = wgs84_to_bessel(lat, lon)
131
+ bessel_e2 = 2 * BESSEL_F - BESSEL_F**2
132
+ second_eccentricity_squared = bessel_e2 / (1 - bessel_e2)
133
+
134
+ sin_lat = sin(lat_rad)
135
+ cos_lat = cos(lat_rad)
136
+ tan_lat = tan(lat_rad)
137
+
138
+ prime_vertical_radius = BESSEL_A / sqrt(1 - bessel_e2 * sin_lat * sin_lat)
139
+ tan_squared = tan_lat * tan_lat
140
+ curvature = second_eccentricity_squared * cos_lat * cos_lat
141
+ A = (lon_rad - AIR_KOREA_TM_LON0) * cos_lat
142
+
143
+ meridional = meridional_arc(lat_rad, semi_major_axis=BESSEL_A, eccentricity_squared=bessel_e2)
144
+ meridional_origin = meridional_arc(
145
+ AIR_KOREA_TM_LAT0,
146
+ semi_major_axis=BESSEL_A,
147
+ eccentricity_squared=bessel_e2,
148
+ )
149
+
150
+ tm_x = AIR_KOREA_TM_FALSE_EASTING + AIR_KOREA_TM_SCALE * prime_vertical_radius * (
151
+ A
152
+ + (1 - tan_squared + curvature) * A**3 / 6
153
+ + (5 - 18 * tan_squared + tan_squared**2 + 72 * curvature - 58 * second_eccentricity_squared) * A**5 / 120
154
+ )
155
+ tm_y = AIR_KOREA_TM_FALSE_NORTHING + AIR_KOREA_TM_SCALE * (
156
+ meridional
157
+ - meridional_origin
158
+ + prime_vertical_radius
159
+ * tan_lat
160
+ * (
161
+ A**2 / 2
162
+ + (5 - tan_squared + 9 * curvature + 4 * curvature**2) * A**4 / 24
163
+ + (61 - 58 * tan_squared + tan_squared**2 + 600 * curvature - 330 * second_eccentricity_squared)
164
+ * A**6
165
+ / 720
166
+ )
167
+ )
168
+ return tm_x, tm_y
169
+
170
+
171
+ def pick_station(
172
+ station_items: list[dict],
173
+ *,
174
+ lat: float | None = None,
175
+ lon: float | None = None,
176
+ region_hint: str | None = None,
177
+ station_name: str | None = None,
178
+ ) -> dict:
179
+ if not station_items:
180
+ raise SystemExit("측정소 후보가 없습니다.")
181
+
182
+ if station_name:
183
+ exact_match = next((item for item in station_items if item.get("stationName") == station_name), None)
184
+ if exact_match:
185
+ return exact_match
186
+ partial_match = next(
187
+ (
188
+ item
189
+ for item in station_items
190
+ if station_name in str(item.get("stationName", "")) or station_name in str(item.get("addr", ""))
191
+ ),
192
+ None,
193
+ )
194
+ if partial_match:
195
+ return partial_match
196
+
197
+ if lat is not None and lon is not None:
198
+ candidates = []
199
+ for item in station_items:
200
+ item_lat = to_float(item.get("dmX"))
201
+ item_lon = to_float(item.get("dmY"))
202
+ if item_lat is None or item_lon is None:
203
+ continue
204
+ candidates.append((squared_distance(lat, lon, item_lat, item_lon), item))
205
+ if candidates:
206
+ candidates.sort(key=lambda pair: pair[0])
207
+ return candidates[0][1]
208
+
209
+ if region_hint:
210
+ tokens = sorted({token for token in region_hint.split() if token}, key=len, reverse=True)
211
+ for token in tokens:
212
+ station_name_match = next(
213
+ (item for item in station_items if token in str(item.get("stationName", ""))),
214
+ None,
215
+ )
216
+ if station_name_match:
217
+ return station_name_match
218
+
219
+ address_match = next(
220
+ (item for item in station_items if token in str(item.get("addr", ""))),
221
+ None,
222
+ )
223
+ if address_match:
224
+ return address_match
225
+
226
+ return station_items[0]
227
+
228
+
229
+ def resolve_station(
230
+ station_items: list[dict],
231
+ *,
232
+ lat: float | None = None,
233
+ lon: float | None = None,
234
+ region_hint: str | None = None,
235
+ station_name: str | None = None,
236
+ ) -> dict:
237
+ if station_items:
238
+ return pick_station(
239
+ station_items,
240
+ lat=lat,
241
+ lon=lon,
242
+ region_hint=region_hint,
243
+ station_name=station_name,
244
+ )
245
+
246
+ if station_name:
247
+ return {"stationName": station_name, "addr": None}
248
+
249
+ raise SystemExit("측정소 후보가 없습니다.")
250
+
251
+
252
+ def find_measurement(measurement_items: list[dict], station_name: str) -> dict:
253
+ exact_match = next((item for item in measurement_items if item.get("stationName") == station_name), None)
254
+ if exact_match:
255
+ return exact_match
256
+
257
+ partial_match = next(
258
+ (item for item in measurement_items if station_name in str(item.get("stationName", ""))),
259
+ None,
260
+ )
261
+ if partial_match:
262
+ return partial_match
263
+
264
+ raise SystemExit(f"측정값 응답에서 측정소 '{station_name}' 를 찾지 못했습니다.")
265
+
266
+
267
+ def grade_to_label(raw_grade: object, *, pollutant: str, value: object) -> str:
268
+ raw_text = str(raw_grade) if raw_grade not in (None, "") else ""
269
+ if raw_text in GRADE_LABELS:
270
+ return GRADE_LABELS[raw_text]
271
+
272
+ numeric_value = to_float(value)
273
+ if numeric_value is None:
274
+ return "정보없음"
275
+
276
+ thresholds = {
277
+ "pm10": [(30, "좋음"), (80, "보통"), (150, "나쁨")],
278
+ "pm25": [(15, "좋음"), (35, "보통"), (75, "나쁨")],
279
+ }[pollutant]
280
+
281
+ for threshold, label in thresholds:
282
+ if numeric_value <= threshold:
283
+ return label
284
+ return "매우나쁨"
285
+
286
+
287
+ def build_report(
288
+ *,
289
+ station_items: list[dict],
290
+ measurement_items: list[dict],
291
+ lat: float | None = None,
292
+ lon: float | None = None,
293
+ region_hint: str | None = None,
294
+ station_name: str | None = None,
295
+ lookup_mode: str | None = None,
296
+ selected_station: dict | None = None,
297
+ ) -> dict:
298
+ station = selected_station or resolve_station(
299
+ station_items,
300
+ lat=lat,
301
+ lon=lon,
302
+ region_hint=region_hint,
303
+ station_name=station_name,
304
+ )
305
+ measurement = find_measurement(measurement_items, station["stationName"])
306
+
307
+ resolved_lookup_mode = lookup_mode or ("coordinates" if lat is not None and lon is not None else "fallback")
308
+
309
+ return {
310
+ "station_name": station["stationName"],
311
+ "station_address": station.get("addr"),
312
+ "lookup_mode": resolved_lookup_mode,
313
+ "measured_at": measurement.get("dataTime"),
314
+ "pm10": {
315
+ "value": str(measurement.get("pm10Value", "-")),
316
+ "grade": grade_to_label(
317
+ measurement.get("pm10Grade"),
318
+ pollutant="pm10",
319
+ value=measurement.get("pm10Value"),
320
+ ),
321
+ },
322
+ "pm25": {
323
+ "value": str(measurement.get("pm25Value", "-")),
324
+ "grade": grade_to_label(
325
+ measurement.get("pm25Grade"),
326
+ pollutant="pm25",
327
+ value=measurement.get("pm25Value"),
328
+ ),
329
+ },
330
+ "khai_grade": "정보없음"
331
+ if measurement.get("khaiGrade") in (None, "")
332
+ else grade_to_label(
333
+ measurement.get("khaiGrade"),
334
+ pollutant="pm10",
335
+ value=measurement.get("pm10Value"),
336
+ ),
337
+ }
338
+
339
+
340
+ def build_missing_secret_message() -> str:
341
+ return (
342
+ f"이 작업에는 {SECRET_NAME} 환경변수가 필요합니다.\n"
343
+ "환경변수가 설정되어 있지 않으면 ~/.config/k-skill/secrets.env 에 추가하거나\n"
344
+ "에이전트의 secret vault에서 주입해 주세요."
345
+ )
346
+
347
+
348
+ def get_required_secret() -> str:
349
+ value = os.environ.get(SECRET_NAME)
350
+ if not value or value == "replace-me":
351
+ raise SystemExit(build_missing_secret_message())
352
+ return value
353
+
354
+
355
+ def get_proxy_base_url() -> str | None:
356
+ value = os.environ.get(PROXY_BASE_URL_NAME)
357
+ if value and value.lower() in {"off", "false", "0", "disable", "disabled", "none"}:
358
+ return None
359
+ if value and value != "replace-me":
360
+ return value.rstrip("/")
361
+ return DEFAULT_PROXY_BASE_URL
362
+
363
+
364
+ def read_json_response(request: urllib.request.Request | str) -> dict:
365
+ try:
366
+ with urllib.request.urlopen(request, timeout=20) as response:
367
+ return json.load(response)
368
+ except urllib.error.HTTPError as exc:
369
+ body = exc.read().decode("utf-8", errors="replace")
370
+ try:
371
+ payload = json.loads(body)
372
+ except json.JSONDecodeError:
373
+ payload = None
374
+
375
+ if exc.code == 503 and isinstance(payload, dict) and payload.get("error") == "upstream_not_configured":
376
+ raise SystemExit(PROXY_KEY_NOT_CONFIGURED_MSG) from exc
377
+
378
+ message = payload.get("message") if isinstance(payload, dict) else None
379
+ if isinstance(payload, dict) and payload.get("error") == "ambiguous_location":
380
+ candidates = payload.get("candidate_stations") or []
381
+ sido_name = payload.get("sido_name")
382
+ detail = [message or "단일 측정소를 확정하지 못했습니다."]
383
+ if sido_name:
384
+ detail.append(f"시도: {sido_name}")
385
+ if candidates:
386
+ detail.append(f"후보 측정소: {', '.join(candidates)}")
387
+ detail.append("위 후보 중 정확한 측정소명으로 --station-name 재조회하세요.")
388
+ raise SystemExit("\n".join(detail)) from exc
389
+
390
+ raise SystemExit(message or f"요청이 실패했습니다: HTTP {exc.code}") from exc
391
+ except urllib.error.URLError as exc:
392
+ raise SystemExit(f"{PROXY_DOWN_MSG} (상세: {exc.reason})") from exc
393
+
394
+
395
+ def fetch_json(url: str, params: dict[str, object]) -> dict:
396
+ query = urllib.parse.urlencode({key: value for key, value in params.items() if value is not None})
397
+ request_url = f"{url}?{query}"
398
+ return read_json_response(request_url)
399
+
400
+
401
+ def fetch_proxy_report(args: argparse.Namespace) -> dict | None:
402
+ base_url = get_proxy_base_url()
403
+ if not base_url or args.station_file or args.measurement_file:
404
+ return None
405
+
406
+ params: dict[str, object] = {}
407
+ if args.lat is not None:
408
+ params["lat"] = args.lat
409
+ if args.lon is not None:
410
+ params["lon"] = args.lon
411
+ if args.region_hint:
412
+ params["regionHint"] = args.region_hint
413
+ if args.station_name:
414
+ params["stationName"] = args.station_name
415
+
416
+ query = urllib.parse.urlencode(params)
417
+ request = urllib.request.Request(f"{base_url}/v1/fine-dust/report?{query}")
418
+ return read_json_response(request)
419
+
420
+
421
+ def fetch_station_lookup(args: argparse.Namespace) -> tuple[dict, str]:
422
+ if args.station_file:
423
+ return load_json_file(args.station_file), "coordinates" if args.lat is not None and args.lon is not None else "fallback"
424
+
425
+ service_key = get_required_secret()
426
+ common = {
427
+ "serviceKey": service_key,
428
+ "returnType": "json",
429
+ "numOfRows": 50,
430
+ "pageNo": 1,
431
+ }
432
+
433
+ if args.lat is not None and args.lon is not None:
434
+ tm_x, tm_y = wgs84_to_air_korea_tm(args.lat, args.lon)
435
+ nearby_payload = fetch_json(
436
+ f"{STATION_SERVICE_URL}/getNearbyMsrstnList",
437
+ {
438
+ **common,
439
+ "numOfRows": 10,
440
+ "tmX": tm_x,
441
+ "tmY": tm_y,
442
+ },
443
+ )
444
+ if extract_items(nearby_payload):
445
+ return nearby_payload, "coordinates"
446
+
447
+ if args.region_hint or args.station_name:
448
+ return (
449
+ fetch_json(
450
+ f"{STATION_SERVICE_URL}/getMsrstnList",
451
+ {
452
+ **common,
453
+ "addr": args.region_hint,
454
+ "stationName": args.station_name,
455
+ },
456
+ ),
457
+ "fallback",
458
+ )
459
+
460
+ raise SystemExit("위도/경도 또는 region fallback 이 필요합니다.")
461
+
462
+
463
+ def fetch_station_payload(args: argparse.Namespace) -> dict:
464
+ payload, _ = fetch_station_lookup(args)
465
+ return payload
466
+
467
+
468
+ def fetch_measurement_payload(args: argparse.Namespace, station_name: str) -> dict:
469
+ if args.measurement_file:
470
+ return load_json_file(args.measurement_file)
471
+
472
+ service_key = get_required_secret()
473
+ return fetch_json(
474
+ f"{MEASUREMENT_SERVICE_URL}/getMsrstnAcctoRltmMesureDnsty",
475
+ {
476
+ "serviceKey": service_key,
477
+ "returnType": "json",
478
+ "numOfRows": 100,
479
+ "pageNo": 1,
480
+ "stationName": station_name,
481
+ "dataTerm": "DAILY",
482
+ "ver": "1.4",
483
+ },
484
+ )
485
+
486
+
487
+ def render_text(report: dict) -> str:
488
+ return "\n".join(
489
+ [
490
+ f"측정소: {report['station_name']}",
491
+ f"주소: {report['station_address'] or '-'}",
492
+ f"조회 시각: {report['measured_at']}",
493
+ f"조회 방식: {report['lookup_mode']}",
494
+ f"PM10: {report['pm10']['value']} ({report['pm10']['grade']})",
495
+ f"PM2.5: {report['pm25']['value']} ({report['pm25']['grade']})",
496
+ f"통합대기등급: {report['khai_grade']}",
497
+ ],
498
+ )
499
+
500
+
501
+ def command_report(args: argparse.Namespace) -> None:
502
+ proxy_report = fetch_proxy_report(args)
503
+ if proxy_report is not None:
504
+ if args.json:
505
+ print(json.dumps(proxy_report, ensure_ascii=False, indent=2))
506
+ return
507
+
508
+ print(render_text(proxy_report))
509
+ return
510
+
511
+ station_payload, lookup_mode = fetch_station_lookup(args)
512
+ station_items = extract_items(station_payload)
513
+ station = resolve_station(
514
+ station_items,
515
+ lat=args.lat,
516
+ lon=args.lon,
517
+ region_hint=args.region_hint,
518
+ station_name=args.station_name,
519
+ )
520
+
521
+ measurement_payload = fetch_measurement_payload(args, station["stationName"])
522
+ report = build_report(
523
+ station_items=station_items,
524
+ measurement_items=extract_items(measurement_payload),
525
+ lat=args.lat,
526
+ lon=args.lon,
527
+ region_hint=args.region_hint,
528
+ station_name=station["stationName"],
529
+ lookup_mode=lookup_mode,
530
+ selected_station=station,
531
+ )
532
+
533
+ if args.json:
534
+ print(json.dumps(report, ensure_ascii=False, indent=2))
535
+ return
536
+
537
+ print(render_text(report))
538
+
539
+
540
+ def main(argv: list[str] | None = None) -> int:
541
+ args = parse_args(argv)
542
+ if args.command == "report":
543
+ command_report(args)
544
+ return 0
545
+ raise SystemExit(f"unsupported command: {args.command}")
546
+
547
+
548
+ if __name__ == "__main__":
549
+ sys.exit(main())
@@ -5,5 +5,11 @@
5
5
  "proxy",
6
6
  "lookup"
7
7
  ],
8
- "frontmatter": "name: fine-dust-location\ndescription: 에어코리아 기반 미세먼지/초미세먼지를 지역명 또는 위치 힌트로 조회한다. 기본 경로는 k-skill-proxy의 report endpoint다.\nlicense: MIT\nmetadata:\n category: utility\n locale: ko-KR\n phase: v1"
8
+ "frontmatter": "name: fine-dust-location\ndescription: 에어코리아 기반 미세먼지/초미세먼지를 지역명 또는 위치 힌트로 조회한다. 기본 경로는 k-skill-proxy의 report endpoint다.\nlicense: MIT\nmetadata:\n category: utility\n locale: ko-KR\n phase: v1",
9
+ "bundle": [
10
+ {
11
+ "from": "scripts/fine_dust.py",
12
+ "to": "scripts/fine_dust.py"
13
+ }
14
+ ]
9
15
  }
@@ -167,7 +167,7 @@ else
167
167
  fi
168
168
  ```
169
169
 
170
- repo 전체를 clone받은 경우에는 같은 검증을 `bash scripts/check-setup.sh` 로 실행해도 된다.
170
+ repo 전체를 clone받은 경우에는 같은 검증을 `npx -y @nomadamas/k-skill@0 exec k-skill-setup scripts/check-setup.sh --` 로 실행해도 된다.
171
171
 
172
172
  ### 3. Offer scheduled update checks
173
173
 
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ secrets_file="${1:-$HOME/.config/k-skill/secrets.env}"
5
+
6
+ missing=0
7
+
8
+ if [[ ! -f "$secrets_file" ]]; then
9
+ echo "missing secrets file: $secrets_file"
10
+ missing=1
11
+ else
12
+ perms=$(stat -f '%Lp' "$secrets_file" 2>/dev/null || stat -c '%a' "$secrets_file" 2>/dev/null)
13
+ if [[ "$perms" != "600" ]]; then
14
+ echo "insecure permissions on $secrets_file: $perms (expected 600)"
15
+ missing=1
16
+ fi
17
+ fi
18
+
19
+ if [[ "$missing" -ne 0 ]]; then
20
+ cat <<EOF
21
+ next steps:
22
+ 1. create ~/.config/k-skill/secrets.env with your credentials
23
+ 2. chmod 0600 ~/.config/k-skill/secrets.env
24
+ 3. run this check again
25
+ EOF
26
+ exit 1
27
+ fi
28
+
29
+ echo "k-skill setup looks usable"
@@ -7,5 +7,11 @@
7
7
  "browser",
8
8
  "operations"
9
9
  ],
10
- "frontmatter": "name: k-skill-setup\ndescription: After installing the full k-skill bundle, configure and verify the shared cross-platform setup, then optionally wire update checks and GitHub starring with explicit user consent.\nlicense: MIT\nmetadata:\n category: setup\n locale: ko-KR\n phase: v1"
10
+ "frontmatter": "name: k-skill-setup\ndescription: After installing the full k-skill bundle, configure and verify the shared cross-platform setup, then optionally wire update checks and GitHub starring with explicit user consent.\nlicense: MIT\nmetadata:\n category: setup\n locale: ko-KR\n phase: v1",
11
+ "bundle": [
12
+ {
13
+ "from": "scripts/check-setup.sh",
14
+ "to": "scripts/check-setup.sh"
15
+ }
16
+ ]
11
17
  }