@softspark/ai-toolkit 4.29.2 → 4.30.2

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 (42) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +44 -18
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/ARCHITECTURE.md +2 -2
  5. package/app/mcp-templates/README.md +7 -2
  6. package/app/mcp-templates/rag-mcp-legal.json +11 -0
  7. package/app/mcp-templates/rag-mcp.json +11 -0
  8. package/app/surface.json +1 -0
  9. package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
  10. package/bin/ai-toolkit.js +8 -0
  11. package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
  12. package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
  13. package/kb/procedures/ecosystem-sync-sop.md +7 -5
  14. package/kb/procedures/maintenance-sop.md +1 -1
  15. package/kb/procedures/release-verification-sop.md +35 -5
  16. package/kb/reference/architecture-overview.md +24 -5
  17. package/kb/reference/cli-reference.md +1 -1
  18. package/kb/reference/dsh-compatibility.md +183 -0
  19. package/kb/reference/manifest-install.md +112 -5
  20. package/kb/reference/mcp-templates.md +11 -4
  21. package/kb/reference/plugin-pack-conventions.md +35 -18
  22. package/kb/reference/supported-tools-registry.md +30 -6
  23. package/llms-full.txt +1110 -50
  24. package/llms.txt +3 -0
  25. package/manifest.json +2 -2
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +673 -34
  28. package/scripts/config_resolver.py +80 -14
  29. package/scripts/doctor.py +98 -20
  30. package/scripts/ecosystem_tools.json +51 -1
  31. package/scripts/generate_codex_skills.py +22 -20
  32. package/scripts/install.py +30 -13
  33. package/scripts/install_steps/ai_tools.py +97 -33
  34. package/scripts/install_steps/dsh.py +5063 -0
  35. package/scripts/install_steps/install_state.py +1645 -57
  36. package/scripts/mcp_editors.py +5 -2
  37. package/scripts/plugin.py +2495 -163
  38. package/scripts/plugin_mcp.py +279 -0
  39. package/scripts/plugin_rules.py +389 -0
  40. package/scripts/plugin_schema.py +139 -23
  41. package/scripts/uninstall.py +47 -4
  42. package/scripts/validate.py +421 -0
@@ -91,6 +91,7 @@ def resolve_extends(
91
91
  project_root: str | Path,
92
92
  *,
93
93
  refresh: bool = False,
94
+ persistent: bool = True,
94
95
  ) -> ResolutionResult:
95
96
  """Resolve an extends chain into an ordered list of base configs.
96
97
 
@@ -98,6 +99,9 @@ def resolve_extends(
98
99
  extends_value: The extends string from .softspark-toolkit.json.
99
100
  project_root: The project directory (for resolving relative paths).
100
101
  refresh: Force re-fetch ignoring cache.
102
+ persistent: Allow remote sources to populate the config cache. When
103
+ false, cached sources remain readable and cache misses resolve in
104
+ an isolated staging directory that is removed before return.
101
105
 
102
106
  Returns:
103
107
  ResolutionResult with ordered configs (deepest ancestor first).
@@ -106,7 +110,26 @@ def resolve_extends(
106
110
  ConfigResolverError: On resolution failure.
107
111
  """
108
112
  result = ResolutionResult()
109
- _resolve_chain(extends_value, Path(project_root), set(), result, refresh=refresh)
113
+ if persistent:
114
+ _resolve_chain(
115
+ extends_value,
116
+ Path(project_root),
117
+ set(),
118
+ result,
119
+ refresh=refresh,
120
+ staging_root=None,
121
+ )
122
+ return result
123
+
124
+ with tempfile.TemporaryDirectory(prefix="ai-toolkit-config-preview-") as tmp:
125
+ _resolve_chain(
126
+ extends_value,
127
+ Path(project_root),
128
+ set(),
129
+ result,
130
+ refresh=refresh,
131
+ staging_root=Path(tmp),
132
+ )
110
133
  return result
111
134
 
112
135
 
@@ -148,6 +171,7 @@ def _resolve_chain(
148
171
  result: ResolutionResult,
149
172
  *,
150
173
  refresh: bool = False,
174
+ staging_root: Path | None,
151
175
  ) -> None:
152
176
  """Recursively resolve extends chain with cycle + depth detection."""
153
177
  # Cycle detection
@@ -169,7 +193,13 @@ def _resolve_chain(
169
193
  visited.add(canonical)
170
194
 
171
195
  # Resolve this source
172
- base_config = _resolve_source(extends_value, project_root, result, refresh=refresh)
196
+ base_config = _resolve_source(
197
+ extends_value,
198
+ project_root,
199
+ result,
200
+ refresh=refresh,
201
+ staging_root=staging_root,
202
+ )
173
203
  _validate_resolved_base(base_config)
174
204
 
175
205
  # Recurse if this base also extends something
@@ -180,6 +210,7 @@ def _resolve_chain(
180
210
  visited,
181
211
  result,
182
212
  refresh=refresh,
213
+ staging_root=staging_root,
183
214
  )
184
215
 
185
216
  # Append after recursion (deepest ancestor first)
@@ -205,14 +236,25 @@ def _resolve_source(
205
236
  result: ResolutionResult,
206
237
  *,
207
238
  refresh: bool = False,
239
+ staging_root: Path | None,
208
240
  ) -> BaseConfig:
209
241
  """Resolve a single extends source."""
210
242
  if source.startswith("git+"):
211
- return _resolve_git(source, result, refresh=refresh)
243
+ return _resolve_git(
244
+ source,
245
+ result,
246
+ refresh=refresh,
247
+ staging_root=staging_root,
248
+ )
212
249
  if source.startswith(".") or source.startswith("/") or source.startswith("~"):
213
250
  return _resolve_local(source, project_root)
214
251
  # Default: npm package
215
- return _resolve_npm(source, result, refresh=refresh)
252
+ return _resolve_npm(
253
+ source,
254
+ result,
255
+ refresh=refresh,
256
+ staging_root=staging_root,
257
+ )
216
258
 
217
259
 
218
260
  # ---------------------------------------------------------------------------
@@ -224,6 +266,7 @@ def _resolve_npm(
224
266
  result: ResolutionResult,
225
267
  *,
226
268
  refresh: bool = False,
269
+ staging_root: Path | None,
227
270
  ) -> BaseConfig:
228
271
  """Resolve from npm registry via npm pack."""
229
272
  package_name, version_spec = _parse_npm_source(source)
@@ -270,8 +313,13 @@ def _resolve_npm(
270
313
  tarball = tarballs[0]
271
314
  version = _extract_version_from_tarball(tarball.name, package_name)
272
315
 
273
- # Extract to cache
274
- dest = cache_dir / version
316
+ dest = _remote_destination(
317
+ cache_dir / version,
318
+ staging_root,
319
+ "npm",
320
+ pack_source,
321
+ version,
322
+ )
275
323
  dest.mkdir(parents=True, exist_ok=True)
276
324
  _extract_tarball(tarball, dest)
277
325
 
@@ -366,6 +414,7 @@ def _resolve_git(
366
414
  result: ResolutionResult,
367
415
  *,
368
416
  refresh: bool = False,
417
+ staging_root: Path | None,
369
418
  ) -> BaseConfig:
370
419
  """Resolve from git URL (git+https://...)."""
371
420
  url = source.removeprefix("git+")
@@ -379,16 +428,19 @@ def _resolve_git(
379
428
  if not refresh and cache_dir.is_dir() and (cache_dir / CONFIG_FILENAME).is_file():
380
429
  return _load_cached_config(cache_dir, source)
381
430
 
382
- # Clone (shallow)
383
- cache_dir.mkdir(parents=True, exist_ok=True)
431
+ destination = _remote_destination(
432
+ cache_dir,
433
+ staging_root,
434
+ "git",
435
+ url,
436
+ )
437
+ destination.parent.mkdir(parents=True, exist_ok=True)
384
438
  try:
385
- # Clean previous clone if refreshing
386
- if cache_dir.is_dir():
387
- shutil.rmtree(cache_dir)
388
- cache_dir.mkdir(parents=True)
439
+ if destination.is_dir():
440
+ shutil.rmtree(destination)
389
441
 
390
442
  proc = subprocess.run(
391
- ["git", "clone", "--depth", "1", url, str(cache_dir)],
443
+ ["git", "clone", "--depth", "1", url, str(destination)],
392
444
  capture_output=True,
393
445
  text=True,
394
446
  timeout=120,
@@ -404,7 +456,7 @@ def _resolve_git(
404
456
  f"git clone failed: {proc.stderr.strip()}"
405
457
  )
406
458
 
407
- return _load_cached_config(cache_dir, source)
459
+ return _load_cached_config(destination, source)
408
460
 
409
461
 
410
462
  # ---------------------------------------------------------------------------
@@ -431,6 +483,20 @@ def _resolve_local(source: str, project_root: Path) -> BaseConfig:
431
483
  # Shared helpers
432
484
  # ---------------------------------------------------------------------------
433
485
 
486
+ def _remote_destination(
487
+ persistent_path: Path,
488
+ staging_root: Path | None,
489
+ source_kind: str,
490
+ source: str,
491
+ version: str = "",
492
+ ) -> Path:
493
+ """Choose a persistent cache path or an isolated preview path."""
494
+ if staging_root is None:
495
+ return persistent_path
496
+ source_key = hashlib.sha256(source.encode()).hexdigest()[:16]
497
+ destination = staging_root / source_kind / source_key
498
+ return destination / version if version else destination
499
+
434
500
  def _load_cached_config(
435
501
  config_dir: Path,
436
502
  source: str,
package/scripts/doctor.py CHANGED
@@ -7,16 +7,17 @@
7
7
 
8
8
  Checks:
9
9
  1. Environment prerequisites (node, bash, python3, bats) + check_deps
10
- 2. Global install integrity (symlinks, settings.json hooks)
11
- 3. Hook scripts (existence, executable)
12
- 4. Hook configuration (valid event names)
13
- 5. Generated artifacts (AGENTS.md, llms.txt staleness)
14
- 6. Planned assets
15
- 7. Benchmark freshness
16
- 8. Stale rules
17
- 9. URL hook sources
18
- 10. Language rules drift (project-local)
19
- 11. Plugin double-load (Claude app plugin vs global install)
10
+ 2. Local AI runtime availability and versions (credential-blind)
11
+ 3. Global install integrity (symlinks, settings.json hooks)
12
+ 4. Hook scripts (existence, executable)
13
+ 5. Hook configuration (valid event names)
14
+ 6. Generated artifacts (AGENTS.md, llms.txt staleness)
15
+ 7. Planned assets
16
+ 8. Benchmark freshness
17
+ 9. Stale rules
18
+ 10. URL hook sources
19
+ 11. Language rules drift (project-local)
20
+ 12. Plugin double-load (Claude app plugin vs global install)
20
21
 
21
22
  Exit codes:
22
23
  0 all checks pass
@@ -24,6 +25,7 @@ Exit codes:
24
25
  """
25
26
  from __future__ import annotations
26
27
 
28
+ from dataclasses import dataclass
27
29
  import json
28
30
  import os
29
31
  import re
@@ -100,6 +102,32 @@ PLANNED_ASSETS = [
100
102
  toolkit_dir / "app" / "skills" / "agent-creator" / "SKILL.md",
101
103
  ]
102
104
 
105
+ AI_RUNTIME_BINARIES = (
106
+ ("dsh", "DSH"),
107
+ ("codex", "Codex"),
108
+ ("claude", "Claude Code"),
109
+ ("copilot", "GitHub Copilot"),
110
+ )
111
+ AI_RUNTIME_VERSION_TIMEOUT_SECONDS = 5
112
+ _SEMVER_NUMERIC_IDENTIFIER = r"(?:0|[1-9][0-9]*)"
113
+ _SEMVER_PRERELEASE_IDENTIFIER = (
114
+ rf"(?:{_SEMVER_NUMERIC_IDENTIFIER}|"
115
+ r"[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)"
116
+ )
117
+ _SEMVER_BUILD_IDENTIFIER = r"[0-9A-Za-z-]+"
118
+ SEMVER_OUTPUT = re.compile(
119
+ r"(?<![0-9A-Za-z.+-])v?"
120
+ rf"(?P<version>{_SEMVER_NUMERIC_IDENTIFIER}\."
121
+ rf"{_SEMVER_NUMERIC_IDENTIFIER}\."
122
+ rf"{_SEMVER_NUMERIC_IDENTIFIER}"
123
+ rf"(?:-{_SEMVER_PRERELEASE_IDENTIFIER}"
124
+ rf"(?:\.{_SEMVER_PRERELEASE_IDENTIFIER})*)?"
125
+ rf"(?:\+{_SEMVER_BUILD_IDENTIFIER}"
126
+ rf"(?:\.{_SEMVER_BUILD_IDENTIFIER})*)?)"
127
+ r"(?![0-9A-Za-z.+-])"
128
+ )
129
+ VERSION_LIKE_OUTPUT = re.compile(r"(?<![0-9])[0-9]+\.[0-9]+\.[^\s,;()]+")
130
+
103
131
 
104
132
  # ---------------------------------------------------------------------------
105
133
  # Status helpers
@@ -134,20 +162,53 @@ class DiagResult:
134
162
  # Version extraction
135
163
  # ---------------------------------------------------------------------------
136
164
 
137
- def _get_version(binary: str) -> str:
138
- """Get a version string from a binary."""
165
+ @dataclass(frozen=True, slots=True)
166
+ class VersionProbe:
167
+ """Result of a bounded, non-authenticating ``--version`` probe."""
168
+
169
+ version: str | None
170
+ error: str | None
171
+
172
+
173
+ def _probe_version(executable: str) -> VersionProbe:
174
+ """Run the exact discovered executable and parse its SemVer output."""
139
175
  try:
140
176
  result = subprocess.run(
141
- [binary, "--version"],
177
+ [executable, "--version"],
142
178
  capture_output=True,
143
179
  text=True,
144
- timeout=5,
180
+ timeout=AI_RUNTIME_VERSION_TIMEOUT_SECONDS,
145
181
  )
182
+ except FileNotFoundError:
183
+ return VersionProbe(None, "executable disappeared before version check")
184
+ except subprocess.TimeoutExpired:
185
+ return VersionProbe(None, "version check timed out")
186
+ except UnicodeError:
187
+ return VersionProbe(None, "version output is not valid text")
188
+ except OSError as error:
189
+ detail = error.strerror or type(error).__name__
190
+ return VersionProbe(None, f"version check failed: {detail}")
191
+
192
+ if result.returncode != 0:
193
+ return VersionProbe(
194
+ None,
195
+ f"version command exited with status {result.returncode}",
196
+ )
197
+ try:
146
198
  output = result.stdout.strip() or result.stderr.strip()
147
- m = re.search(r"(\d+\.\d+\.\d+)", output)
148
- return m.group(1) if m else "unknown"
149
- except (FileNotFoundError, subprocess.TimeoutExpired):
150
- return ""
199
+ except UnicodeError:
200
+ return VersionProbe(None, "version output is not valid text")
201
+ match = SEMVER_OUTPUT.search(output)
202
+ if not match:
203
+ if VERSION_LIKE_OUTPUT.search(output):
204
+ return VersionProbe(None, "version output contained invalid SemVer")
205
+ return VersionProbe(None, "version output did not contain SemVer")
206
+ return VersionProbe(match.group("version"), None)
207
+
208
+
209
+ def _get_version(executable: str) -> str:
210
+ """Compatibility helper for required environment binary reporting."""
211
+ return _probe_version(executable).version or ""
151
212
 
152
213
 
153
214
  # ---------------------------------------------------------------------------
@@ -165,8 +226,8 @@ def check_environment(dr: DiagResult, fix_mode: bool) -> None:
165
226
  ("python3", "python3", False),
166
227
  ("bats", "bats", False),
167
228
  ]:
168
- if shutil.which(binary):
169
- version = _get_version(binary)
229
+ if executable := shutil.which(binary):
230
+ version = _get_version(executable)
170
231
  ver_str = f" {version}" if version else ""
171
232
  dr.ok(f"{label}{ver_str}")
172
233
  else:
@@ -191,6 +252,22 @@ def check_environment(dr: DiagResult, fix_mode: bool) -> None:
191
252
  print()
192
253
 
193
254
 
255
+ def check_ai_runtimes(dr: DiagResult) -> None:
256
+ """Report local AI runtime availability without inspecting login state."""
257
+ print("## AI Runtimes")
258
+ for binary, label in AI_RUNTIME_BINARIES:
259
+ executable = shutil.which(binary)
260
+ if not executable:
261
+ dr.skip(f"{label} ({binary}) not found")
262
+ continue
263
+ probe = _probe_version(executable)
264
+ if probe.version:
265
+ dr.ok(f"{label} ({binary}) {probe.version}")
266
+ else:
267
+ dr.warn(f"{label} ({binary}) detected but {probe.error}")
268
+ print()
269
+
270
+
194
271
  # ---------------------------------------------------------------------------
195
272
  # Check 2: Global Install
196
273
  # ---------------------------------------------------------------------------
@@ -839,6 +916,7 @@ def main() -> None:
839
916
  dr = DiagResult()
840
917
 
841
918
  check_environment(dr, fix_mode)
919
+ check_ai_runtimes(dr)
842
920
  check_global_install(dr, fix_mode)
843
921
  check_hook_scripts(dr, fix_mode)
844
922
  check_hook_configuration(dr)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "description": "Authoritative registry of tools ai-toolkit integrates with. Consumed by scripts/ecosystem_doctor.py to detect upstream doc/version drift.",
4
- "last_updated": "2026-08-19",
4
+ "last_updated": "2026-09-01",
5
5
  "tools": [
6
6
  {
7
7
  "id": "claude-code",
@@ -130,6 +130,56 @@
130
130
  ],
131
131
  "version_probe": null
132
132
  },
133
+ {
134
+ "id": "dsh",
135
+ "display_name": "DeepSeek Harness",
136
+ "kind": "harness",
137
+ "status": "developer-preview",
138
+ "selection_policy": "explicit-only",
139
+ "reviewed_version": "0.1.1-rc.2",
140
+ "excluded_from": [
141
+ "editors-all",
142
+ "auto-detect",
143
+ "defaults"
144
+ ],
145
+ "urls": {
146
+ "docs": "https://deepseek-harness.github.io/deepseek-harness/",
147
+ "release_notes": "https://github.com/deepseek-ai/deepseek-harness/releases",
148
+ "reviewed_release": "https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.1-rc.2",
149
+ "reviewed_cli_docs": "https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.1-rc.2/apps/cli/reference/README.md",
150
+ "reviewed_skill_docs": "https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.1-rc.2/docs/subsystems/skills.md"
151
+ },
152
+ "config_paths": [
153
+ ".agents/skills/*/SKILL.md",
154
+ "$DSH_HOME/profiles/<profile>/package.json",
155
+ "$DSH_HOME/profiles/<profile>/node_modules/@softspark/dsh-*",
156
+ "$DSH_HOME/.agent-presets/softspark-orchestrator"
157
+ ],
158
+ "our_generators": [
159
+ "scripts/generate_codex_skills.py"
160
+ ],
161
+ "our_lifecycle": [
162
+ "scripts/install_steps/dsh.py"
163
+ ],
164
+ "lifecycle_commands": [
165
+ "ai-toolkit dsh install --profile web",
166
+ "ai-toolkit dsh update --profile web",
167
+ "ai-toolkit dsh doctor --profile web",
168
+ "ai-toolkit dsh uninstall --profile web"
169
+ ],
170
+ "status_note": "Community compatibility target maintained by SoftSpark. DeepSeek AI has not endorsed this integration. ai-toolkit reuses its native skill generator for project .agents/skills output and manages exact @softspark/dsh-codex@1.0.0 and @softspark/dsh-orchestrator@1.0.1 packages only through explicit profile lifecycle commands. Upstream has newer prereleases, but this entry remains pinned to the reviewed DSH 0.1.1-rc.2 contract pending qualification.",
171
+ "capability_markers": [
172
+ "Developer preview",
173
+ "profiles",
174
+ "dsh plugin",
175
+ "skills",
176
+ "Agent Preset"
177
+ ],
178
+ "version_probe": {
179
+ "kind": "command",
180
+ "command": "dsh --version"
181
+ }
182
+ },
133
183
  {
134
184
  "id": "cursor",
135
185
  "display_name": "Cursor",
@@ -3,7 +3,7 @@
3
3
  # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
4
4
  # Source: https://github.com/softspark/ai-toolkit
5
5
 
6
- """Mirror the ai-toolkit skill catalogue into Codex ``.agents/skills/``.
6
+ """Mirror the ai-toolkit skill catalogue into ``.agents/skills/``.
7
7
 
8
8
  OpenAI Codex CLI discovers Agent Skills from ``.agents/skills/`` in the
9
9
  repository tree, plus user/admin/system skill locations. Unlike the Augment
@@ -13,7 +13,8 @@ on disk, so this generator syncs every skill in ``app/skills/`` into
13
13
 
14
14
  The standalone generator keeps ``enable_codex_skills=False`` as a compatibility
15
15
  default. Selecting Codex in the main installer installs this catalog
16
- automatically; ``--codex-skills`` remains an explicit refresh option.
16
+ automatically; ``--codex-skills`` remains an explicit refresh option. DSH
17
+ reuses the same one-level managed surface without receiving other Codex config.
17
18
 
18
19
  Implementation:
19
20
  * Native Codex-compatible skills are symlinked to canonical ``app/skills``.
@@ -40,7 +41,7 @@ from pathlib import Path
40
41
  sys.path.insert(0, str(Path(__file__).resolve().parent))
41
42
  from codex_skill_adapter import (
42
43
  cleanup_codex_skills,
43
- prepare_codex_skills_dir,
44
+ managed_skill_surface_transaction,
44
45
  sync_codex_skill,
45
46
  unmanaged_codex_skill_names,
46
47
  )
@@ -104,27 +105,28 @@ def generate(target_dir: Path, enable_codex_skills: bool = False) -> None:
104
105
  if not enable_codex_skills:
105
106
  return
106
107
 
107
- codex_skills_dir = prepare_codex_skills_dir(target_dir)
108
-
109
108
  sources = _iter_source_skills()
110
- user_names = unmanaged_codex_skill_names(codex_skills_dir, skills_dir)
111
-
112
109
  linked = 0
113
110
  adapted = 0
114
111
  skipped = 0
115
- for skill in sources:
116
- if skill.name in user_names:
117
- skipped += 1
118
- continue
119
- mode = sync_codex_skill(skill, codex_skills_dir)
120
- if mode == "linked":
121
- linked += 1
122
- elif mode == "adapted":
123
- adapted += 1
124
- else:
125
- skipped += 1
126
-
127
- cleanup_codex_skills(codex_skills_dir, skills_dir, user_names)
112
+ with managed_skill_surface_transaction(target_dir, skills_dir) as transaction:
113
+ codex_skills_dir = transaction.skills_dst
114
+ user_names = unmanaged_codex_skill_names(codex_skills_dir, skills_dir)
115
+
116
+ for skill in sources:
117
+ if skill.name in user_names:
118
+ skipped += 1
119
+ continue
120
+ mode = sync_codex_skill(skill, codex_skills_dir)
121
+ if mode == "linked":
122
+ linked += 1
123
+ elif mode == "adapted":
124
+ adapted += 1
125
+ else:
126
+ skipped += 1
127
+
128
+ cleanup_codex_skills(codex_skills_dir, skills_dir, user_names)
129
+ transaction.commit({"codex"})
128
130
 
129
131
  print(
130
132
  f" Codex skill mirror: {_count_entries(codex_skills_dir)} skills "
@@ -76,7 +76,7 @@ from config_resolver import (
76
76
  )
77
77
  from config_merger import ConfigMergeError, merge_config_chain
78
78
  from config_validator import validate_project_config
79
- from config_lock import save_lock_file
79
+ from config_lock import LOCK_FILENAME, save_lock_file
80
80
 
81
81
 
82
82
  # ---------------------------------------------------------------------------
@@ -317,7 +317,7 @@ VALID_LANGS = {"python", "typescript", "golang", "go", "rust", "java", "kotlin",
317
317
 
318
318
  def validate_args(cfg: dict) -> None:
319
319
  """Validate parsed arguments — exit non-zero on invalid values."""
320
- from install_steps.ai_tools import ALL_EDITORS
320
+ from install_steps.ai_tools import LOCAL_ONLY_EDITORS, SELECTABLE_EDITORS
321
321
 
322
322
  errors: list[str] = []
323
323
 
@@ -339,8 +339,13 @@ def validate_args(cfg: dict) -> None:
339
339
  if cfg["editors"] and cfg["editors"] != "all":
340
340
  for e in cfg["editors"].split(","):
341
341
  e = e.strip()
342
- if e and e not in ALL_EDITORS:
343
- errors.append(f"Unknown editor: '{e}' (valid: {', '.join(ALL_EDITORS)}, all)")
342
+ if e and e not in SELECTABLE_EDITORS:
343
+ errors.append(
344
+ f"Unknown editor: '{e}' "
345
+ f"(valid: {', '.join(SELECTABLE_EDITORS)}, all)"
346
+ )
347
+ if e in LOCAL_ONLY_EDITORS and not cfg["local"]:
348
+ errors.append(f"Editor '{e}' is project-local and requires --local")
344
349
 
345
350
  # Validate --lang
346
351
  if cfg["lang"]:
@@ -536,6 +541,8 @@ def resolve_extends_config(
536
541
  project_dir: Path,
537
542
  config_path: str = "",
538
543
  refresh: bool = False,
544
+ *,
545
+ persist_lock: bool = True,
539
546
  ) -> dict | None:
540
547
  """Resolve .softspark-toolkit.json extends and return merged config.
541
548
 
@@ -574,7 +581,12 @@ def resolve_extends_config(
574
581
  print(f" Resolving extends: {extends}...")
575
582
 
576
583
  try:
577
- result = resolve_extends(extends, config_root, refresh=refresh)
584
+ result = resolve_extends(
585
+ extends,
586
+ config_root,
587
+ refresh=refresh,
588
+ persistent=persist_lock,
589
+ )
578
590
  except ConfigResolverError as e:
579
591
  print(f" ✗ Resolution failed: {e}")
580
592
  sys.exit(1)
@@ -611,13 +623,15 @@ def resolve_extends_config(
611
623
  "overrides_applied": merge_result.overrides_applied,
612
624
  }
613
625
 
614
- # Generate lock file
615
- lock_path = save_lock_file(
616
- config_root,
617
- config_metas,
618
- ai_toolkit_version=_get_toolkit_version(),
619
- )
620
- print(f" Saved: {lock_path.name}")
626
+ if persist_lock:
627
+ lock_path = save_lock_file(
628
+ config_root,
629
+ config_metas,
630
+ ai_toolkit_version=_get_toolkit_version(),
631
+ )
632
+ print(f" Saved: {lock_path.name}")
633
+ else:
634
+ print(f" Would save: {LOCK_FILENAME} (dry-run)")
621
635
 
622
636
  return merge_result.merged
623
637
 
@@ -736,7 +750,10 @@ def main() -> None:
736
750
  config_path_arg: str = cfg["config"]
737
751
  refresh_base: bool = cfg["refresh_base"]
738
752
  merged_config = resolve_extends_config(
739
- project_dir, config_path=config_path_arg, refresh=refresh_base,
753
+ project_dir,
754
+ config_path=config_path_arg,
755
+ refresh=refresh_base,
756
+ persist_lock=not dry_run,
740
757
  )
741
758
  if merged_config:
742
759
  cfg = _apply_merged_config(merged_config, cfg)