specfuse 0.2.4__tar.gz → 0.2.6__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Specfuse umbrella CLI — bridges the pip-installed driver and the Claude Code plugin (init / upgrade).
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -17,7 +17,7 @@ Requires-Python: >=3.10
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  License-File: NOTICE
20
- Requires-Dist: specfuse-loop>=0.3.2
20
+ Requires-Dist: specfuse-loop>=0.3.3
21
21
  Provides-Extra: dev
22
22
  Requires-Dist: coverage>=7.0; extra == "dev"
23
23
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specfuse"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "Specfuse umbrella CLI — bridges the pip-installed driver and the Claude Code plugin (init / upgrade)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,7 +25,7 @@ classifiers = [
25
25
  # `specfuse.loop.scaffold`, which only exists in specfuse-loop 0.3.0+ (added by
26
26
  # FEAT-2026-0026). A 0.2.x driver would import-fail at `specfuse init`.
27
27
  dependencies = [
28
- "specfuse-loop>=0.3.2",
28
+ "specfuse-loop>=0.3.3",
29
29
  ]
30
30
 
31
31
  [project.optional-dependencies]
@@ -38,7 +38,7 @@ specfuse = "specfuse.cli:main"
38
38
  # not its dependencies' — without these, `specfuse-loop`/`specfuse-lint` (from
39
39
  # the specfuse-loop dep) would be hidden and users would have to install the
40
40
  # driver separately or pass --include-deps. The targets resolve against the
41
- # specfuse.loop modules the pinned specfuse-loop>=0.3.2 dependency provides.
41
+ # specfuse.loop modules the pinned specfuse-loop>=0.3.3 dependency provides.
42
42
  specfuse-loop = "specfuse.loop.loop:main"
43
43
  specfuse-lint = "specfuse.loop.lint_plan:main"
44
44
 
@@ -23,6 +23,7 @@ thin user-facing bridge over it.
23
23
  from __future__ import annotations
24
24
 
25
25
  import argparse
26
+ import importlib.util
26
27
  import shutil
27
28
  import subprocess
28
29
  import sys
@@ -31,7 +32,7 @@ from pathlib import Path
31
32
 
32
33
  from specfuse.loop import scaffold
33
34
 
34
- __version__ = "0.2.4"
35
+ __version__ = "0.2.6"
35
36
 
36
37
  MARKETPLACE = "specfuse/specfuse"
37
38
  PLUGIN = "specfuse@specfuse"
@@ -54,6 +55,50 @@ def _pip_install(packages: list[str], *, upgrade: bool, runner=None) -> int:
54
55
  return getattr(proc, "returncode", 0)
55
56
 
56
57
 
58
+ def _scaffold_is_current(target: Path) -> tuple[bool, str]:
59
+ """Return (already_current, installed_seed_version).
60
+
61
+ already_current is True when the target's .specfuse/VERSION equals the
62
+ installed seed version — i.e. there is nothing newer to overlay.
63
+ """
64
+ installed = scaffold.scaffold_version()
65
+ vpath = target / ".specfuse" / "VERSION"
66
+ if not vpath.exists():
67
+ return (False, installed)
68
+ current = vpath.read_text(encoding="utf-8").strip()
69
+ try:
70
+ same = scaffold._parse_version(current) == scaffold._parse_version(installed)
71
+ except ValueError:
72
+ return (False, installed)
73
+ return (same, installed)
74
+
75
+
76
+ def _pip_upgrade_or_advise(runner=None) -> int:
77
+ """Pip-upgrade the driver + CLI, unless this is a pipx-managed or pip-less
78
+ install — pipx owns its venv (and may ship no pip), so `python -m pip` either
79
+ fails ('No module named pip') or fights pipx. In that case skip and advise
80
+ the right command instead of erroring. Returns a process-style rc (0 = ok or
81
+ cleanly skipped)."""
82
+ pipx_managed = "/pipx/venvs/" in Path(sys.executable).as_posix()
83
+ pip_missing = importlib.util.find_spec("pip") is None
84
+ if pipx_managed or pip_missing:
85
+ why = "pipx-managed install" if pipx_managed else "no pip in this environment"
86
+ print(
87
+ f"specfuse: skipping automatic package upgrade ({why}). Update the "
88
+ f"driver + CLI with:\n"
89
+ f" pipx upgrade specfuse # if installed via pipx\n"
90
+ f" python3 -m pip install -U specfuse # if installed in a venv",
91
+ file=sys.stderr,
92
+ )
93
+ return 0
94
+ rc = _pip_install(["specfuse-loop", "specfuse"], upgrade=True, runner=runner)
95
+ if rc != 0:
96
+ print(f"specfuse: pip upgrade failed (exit {rc}).", file=sys.stderr)
97
+ else:
98
+ print("specfuse: pip packages upgraded (specfuse-loop, specfuse).")
99
+ return rc
100
+
101
+
57
102
  def cmd_init(args: argparse.Namespace, *, runner=None) -> int:
58
103
  """Scaffold a repo's .specfuse/ + .claude wiring from the package (no pip,
59
104
  no curl-bash). Refuses if .specfuse/ already exists (points at `upgrade`)."""
@@ -104,8 +149,13 @@ def cmd_upgrade(args: argparse.Namespace, *, runner=None) -> int:
104
149
  print(f"specfuse: target '{target}' is not a directory.", file=sys.stderr)
105
150
  return 2
106
151
  ci_check = getattr(args, "ci_check", None)
152
+ current, installed = _scaffold_is_current(target)
107
153
 
108
154
  if getattr(args, "dry_run", False):
155
+ if current:
156
+ print(f"specfuse: [dry-run] .specfuse/ is already at the latest "
157
+ f"scaffold version ({installed}); nothing to overlay.")
158
+ return 0
109
159
  # Preview the overlay against a faithful copy of the target's .specfuse/;
110
160
  # the target is never touched and no pip runs.
111
161
  with tempfile.TemporaryDirectory() as tmp:
@@ -137,13 +187,16 @@ def cmd_upgrade(args: argparse.Namespace, *, runner=None) -> int:
137
187
  except scaffold.ScaffoldDowngradeError as exc:
138
188
  print(f"specfuse: {exc}", file=sys.stderr)
139
189
  return 1
140
- print(f"specfuse: overlaid {len(written)} versioned file(s) onto {target}/.specfuse/.")
141
-
142
- rc = _pip_install(["specfuse-loop", "specfuse"], upgrade=True, runner=runner)
190
+ if current:
191
+ print(f"specfuse: .specfuse/ already at the latest scaffold version "
192
+ f"({installed}); .claude wiring refreshed.")
193
+ else:
194
+ print(f"specfuse: overlaid {len(written)} versioned file(s) onto "
195
+ f"{target}/.specfuse/.")
196
+
197
+ rc = _pip_upgrade_or_advise(runner)
143
198
  if rc != 0:
144
- print(f"specfuse: pip upgrade failed (exit {rc}).", file=sys.stderr)
145
199
  return rc
146
- print("specfuse: pip packages upgraded (specfuse-loop, specfuse).")
147
200
  print(PLUGIN_UPDATE_HINT)
148
201
  return 0
149
202
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Specfuse umbrella CLI — bridges the pip-installed driver and the Claude Code plugin (init / upgrade).
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -17,7 +17,7 @@ Requires-Python: >=3.10
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  License-File: NOTICE
20
- Requires-Dist: specfuse-loop>=0.3.2
20
+ Requires-Dist: specfuse-loop>=0.3.3
21
21
  Provides-Extra: dev
22
22
  Requires-Dist: coverage>=7.0; extra == "dev"
23
23
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -1,4 +1,4 @@
1
- specfuse-loop>=0.3.2
1
+ specfuse-loop>=0.3.3
2
2
 
3
3
  [dev]
4
4
  coverage>=7.0
@@ -101,10 +101,16 @@ class TestUpgrade(unittest.TestCase):
101
101
  with redirect_stdout(io.StringIO()):
102
102
  cli.cmd_init(_args(target=d))
103
103
 
104
+ def _make_stale(self, d):
105
+ """Force the target's scaffold VERSION older than the seed so upgrade
106
+ actually overlays (a freshly-init'd target is already current)."""
107
+ (Path(d) / ".specfuse" / "VERSION").write_text("0.1.0\n", encoding="utf-8")
108
+
104
109
  def test_upgrade_overlays_then_pip(self):
105
110
  # Red test for T04: overlay precedes pip; both happen.
106
111
  with tempfile.TemporaryDirectory() as d:
107
112
  self._init(d)
113
+ self._make_stale(d)
108
114
  runner = _ok_runner(0)
109
115
  out = io.StringIO()
110
116
  with redirect_stdout(out):
@@ -144,6 +150,7 @@ class TestUpgrade(unittest.TestCase):
144
150
  them (regression: shutil.Error 'No such file or directory')."""
145
151
  with tempfile.TemporaryDirectory() as d:
146
152
  self._init(d)
153
+ self._make_stale(d) # force an actual overlay so the copytree runs
147
154
  skills = Path(d) / ".specfuse" / "skills"
148
155
  skills.mkdir(parents=True, exist_ok=True)
149
156
  # Point at a target that does not exist → dangling symlink.
@@ -155,6 +162,57 @@ class TestUpgrade(unittest.TestCase):
155
162
  self.assertEqual(rc, 0, "dry-run must survive dangling legacy symlinks")
156
163
  self.assertIn("dry-run", out.getvalue())
157
164
 
165
+ def test_upgrade_dry_run_reports_already_latest(self):
166
+ """A freshly-init'd target is at the seed version; --dry-run must say so,
167
+ not dump the full versioned-file list."""
168
+ with tempfile.TemporaryDirectory() as d:
169
+ self._init(d) # VERSION == installed seed
170
+ out = io.StringIO()
171
+ with redirect_stdout(out):
172
+ rc = cli.cmd_upgrade(_args(target=d, dry_run=True), runner=_ok_runner(0))
173
+ self.assertEqual(rc, 0)
174
+ text = out.getvalue()
175
+ self.assertIn("already at the latest", text)
176
+ self.assertNotIn("would overlay", text)
177
+ self.assertNotIn(" .specfuse/", text) # no file list
178
+
179
+ def test_upgrade_real_reports_already_latest(self):
180
+ """Real upgrade on a current target reports 'already at latest', not a
181
+ misleading 'overlaid N file(s)'."""
182
+ with tempfile.TemporaryDirectory() as d:
183
+ self._init(d)
184
+ out = io.StringIO()
185
+ with redirect_stdout(out):
186
+ rc = cli.cmd_upgrade(_args(target=d), runner=_ok_runner(0))
187
+ self.assertEqual(rc, 0)
188
+ self.assertIn("already at the latest", out.getvalue())
189
+
190
+ def test_pip_step_skipped_when_pip_unavailable(self):
191
+ """In a pip-less (e.g. pipx-managed) environment, the upgrade skips the
192
+ auto pip-install with guidance rather than erroring, and does not call
193
+ the runner."""
194
+ import importlib.util as _ilu
195
+ real_find_spec = _ilu.find_spec
196
+
197
+ def _no_pip(name, *a, **k):
198
+ return None if name == "pip" else real_find_spec(name, *a, **k)
199
+
200
+ runner = _ok_runner(0)
201
+ err = io.StringIO()
202
+ with tempfile.TemporaryDirectory() as d:
203
+ self._init(d)
204
+ self._make_stale(d)
205
+ orig = cli.importlib.util.find_spec
206
+ cli.importlib.util.find_spec = _no_pip
207
+ try:
208
+ with redirect_stderr(err):
209
+ rc = cli.cmd_upgrade(_args(target=d), runner=runner)
210
+ finally:
211
+ cli.importlib.util.find_spec = orig
212
+ self.assertEqual(rc, 0, "pip-less env must not fail the upgrade")
213
+ self.assertEqual(runner.calls, [], "must not attempt pip when pip absent")
214
+ self.assertIn("pipx upgrade specfuse", err.getvalue())
215
+
158
216
 
159
217
  class TestParser(unittest.TestCase):
160
218
 
File without changes
File without changes
File without changes
File without changes