sft-cli 0.2.0__tar.gz → 0.2.1__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.
Files changed (112) hide show
  1. {sft_cli-0.2.0 → sft_cli-0.2.1}/.github/workflows/ci.yml +4 -4
  2. {sft_cli-0.2.0 → sft_cli-0.2.1}/.github/workflows/publish.yml +1 -1
  3. sft_cli-0.2.1/LICENSE +21 -0
  4. sft_cli-0.2.1/PKG-INFO +155 -0
  5. sft_cli-0.2.1/README.md +121 -0
  6. {sft_cli-0.2.0 → sft_cli-0.2.1}/pyproject.toml +2 -0
  7. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/__init__.py +1 -1
  8. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/browser.py +101 -19
  9. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/lora.py +10 -3
  10. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/detect.py +10 -0
  11. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/svd.py +4 -2
  12. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_detect.py +42 -0
  13. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_svd.py +32 -2
  14. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_tui.py +150 -0
  15. sft_cli-0.2.0/PKG-INFO +0 -111
  16. sft_cli-0.2.0/README.md +0 -80
  17. sft_cli-0.2.0/scripts/create_test_file.py +0 -86
  18. sft_cli-0.2.0/scripts/generate_test_models.py +0 -306
  19. {sft_cli-0.2.0 → sft_cli-0.2.1}/.gitignore +0 -0
  20. {sft_cli-0.2.0 → sft_cli-0.2.1}/.pre-commit-config.yaml +0 -0
  21. {sft_cli-0.2.0 → sft_cli-0.2.1}/.python-version +0 -0
  22. {sft_cli-0.2.0 → sft_cli-0.2.1}/AGENTS.md +0 -0
  23. {sft_cli-0.2.0 → sft_cli-0.2.1}/docs/plans/2026-03-06-implementation-plan.md +0 -0
  24. {sft_cli-0.2.0 → sft_cli-0.2.1}/docs/plans/2026-03-06-sft-toolkit-design.md +0 -0
  25. {sft_cli-0.2.0 → sft_cli-0.2.1}/scripts/build_skill_reference.py +0 -0
  26. {sft_cli-0.2.0 → sft_cli-0.2.1}/scripts/hatch_build_hook.py +0 -0
  27. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/cli.py +0 -0
  28. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/__init__.py +0 -0
  29. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/cast.py +0 -0
  30. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/cat.py +0 -0
  31. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/check.py +0 -0
  32. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/convert.py +0 -0
  33. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/diff.py +0 -0
  34. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/info.py +0 -0
  35. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/ls.py +0 -0
  36. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/metadata.py +0 -0
  37. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/rename.py +0 -0
  38. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/skill.py +0 -0
  39. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/slice.py +0 -0
  40. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/split.py +0 -0
  41. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/stat.py +0 -0
  42. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/strip.py +0 -0
  43. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/commands/tree.py +0 -0
  44. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/index.py +0 -0
  45. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/__init__.py +0 -0
  46. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/cast.py +0 -0
  47. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/cat.py +0 -0
  48. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/check.py +0 -0
  49. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/convert.py +0 -0
  50. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/diff.py +0 -0
  51. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/info.py +0 -0
  52. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/__init__.py +0 -0
  53. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/add.py +0 -0
  54. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/compat.py +0 -0
  55. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/convert.py +0 -0
  56. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/extract.py +0 -0
  57. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/info.py +0 -0
  58. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/merge.py +0 -0
  59. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/resize.py +0 -0
  60. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/lora/stack.py +0 -0
  61. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/ls.py +0 -0
  62. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/metadata.py +0 -0
  63. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/rename.py +0 -0
  64. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/slice.py +0 -0
  65. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/split.py +0 -0
  66. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/stat.py +0 -0
  67. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/ops/tree.py +0 -0
  68. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/skill/REFERENCE.md +0 -0
  69. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/skill/SKILL.md +0 -0
  70. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/utils/__init__.py +0 -0
  71. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/utils/dtypes.py +0 -0
  72. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/utils/formatting.py +0 -0
  73. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/utils/glob.py +0 -0
  74. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/utils/linking.py +0 -0
  75. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/utils/output.py +0 -0
  76. {sft_cli-0.2.0 → sft_cli-0.2.1}/src/sft/utils/tensor_io.py +0 -0
  77. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/__init__.py +0 -0
  78. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/conftest.py +0 -0
  79. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_build_hook.py +0 -0
  80. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_cast.py +0 -0
  81. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_cat.py +0 -0
  82. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_check.py +0 -0
  83. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_cli.py +0 -0
  84. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_cli_contract.py +0 -0
  85. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_convert.py +0 -0
  86. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_diff.py +0 -0
  87. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_e2e_subprocess.py +0 -0
  88. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_info.py +0 -0
  89. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_add.py +0 -0
  90. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_compat.py +0 -0
  91. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_convert.py +0 -0
  92. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_extract.py +0 -0
  93. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_info.py +0 -0
  94. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_merge.py +0 -0
  95. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_properties.py +0 -0
  96. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_resize.py +0 -0
  97. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_lora_stack.py +0 -0
  98. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_ls.py +0 -0
  99. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_metadata.py +0 -0
  100. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_perf.py +0 -0
  101. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_pipelines.py +0 -0
  102. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_properties.py +0 -0
  103. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_rename.py +0 -0
  104. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_skill_install.py +0 -0
  105. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_slice.py +0 -0
  106. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_split.py +0 -0
  107. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_stat.py +0 -0
  108. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_strip.py +0 -0
  109. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_tensor_io.py +0 -0
  110. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_tree.py +0 -0
  111. {sft_cli-0.2.0 → sft_cli-0.2.1}/tests/test_utils.py +0 -0
  112. {sft_cli-0.2.0 → sft_cli-0.2.1}/uv.lock +0 -0
@@ -38,7 +38,7 @@ jobs:
38
38
  fail-fast: false
39
39
  matrix:
40
40
  os: [ubuntu-latest, macos-latest]
41
- python-version: ["3.9", "3.11", "3.12"]
41
+ python-version: ["3.9", "3.13", "3.14"]
42
42
  steps:
43
43
  - uses: actions/checkout@v4
44
44
 
@@ -61,7 +61,7 @@ jobs:
61
61
  # Long-running tests (subprocess E2E, perf smoke, build-hook) only need
62
62
  # to pass on one cell — they exercise behavior that's OS- and version-
63
63
  # independent.
64
- name: slow tests (py3.12 / ubuntu)
64
+ name: slow tests (py3.14 / ubuntu)
65
65
  runs-on: ubuntu-latest
66
66
  needs: test
67
67
  steps:
@@ -71,7 +71,7 @@ jobs:
71
71
  uses: astral-sh/setup-uv@v4
72
72
 
73
73
  - name: Set up Python
74
- run: uv python install 3.12
74
+ run: uv python install 3.14
75
75
 
76
76
  - name: Install dependencies
77
77
  run: uv sync --dev
@@ -93,7 +93,7 @@ jobs:
93
93
  uses: astral-sh/setup-uv@v4
94
94
 
95
95
  - name: Set up Python
96
- run: uv python install 3.12
96
+ run: uv python install 3.14
97
97
 
98
98
  - name: Build package
99
99
  run: uv build
@@ -14,7 +14,7 @@ jobs:
14
14
  uses: astral-sh/setup-uv@v4
15
15
 
16
16
  - name: Set up Python
17
- run: uv python install 3.12
17
+ run: uv python install 3.14
18
18
 
19
19
  - name: Build package
20
20
  run: uv build
sft_cli-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matan Ben-Yosef
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sft_cli-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: sft-cli
3
+ Version: 0.2.1
4
+ Summary: An interactive terminal browser for .safetensors files
5
+ Project-URL: Homepage, https://github.com/matanby/sft-cli
6
+ Project-URL: Repository, https://github.com/matanby/sft-cli
7
+ Author: Matan Ben-Yosef
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: browser,cli,machine-learning,safetensors,tui
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: ml-dtypes>=0.3
27
+ Requires-Dist: numpy>=1.24
28
+ Requires-Dist: safetensors>=0.4
29
+ Requires-Dist: textual>=0.40
30
+ Requires-Dist: typer>=0.9
31
+ Provides-Extra: torch
32
+ Requires-Dist: torch>=2.0; extra == 'torch'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # sft
36
+
37
+ > The Swiss army knife for `.safetensors` files.
38
+
39
+ [![PyPI](https://img.shields.io/pypi/v/sft-cli?style=flat-square)](https://pypi.org/project/sft-cli/)
40
+ [![Python](https://img.shields.io/pypi/pyversions/sft-cli?style=flat-square)](https://pypi.org/project/sft-cli/)
41
+ [![CI](https://img.shields.io/github/actions/workflow/status/matanby/sft-cli/ci.yml?branch=main&style=flat-square)](https://github.com/matanby/sft-cli/actions)
42
+ [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
43
+
44
+ <img src="https://vhs.charm.sh/vhs-3nFXGLuvC5swABYCewgchD.gif" alt="sft demo">
45
+
46
+ `sft` is a single-binary CLI for inspecting, editing, and diffing `.safetensors` files — and an interactive terminal browser for poking around large checkpoints. Most commands read the file header only, so multi-gigabyte models open in milliseconds.
47
+
48
+ It also ships a [skill](#-ai-agents) that teaches AI coding agents (Claude Code, Cursor, Codex CLI) when to reach for it and how to parse the output.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ uv tool install sft-cli # recommended
54
+ pip install sft-cli # or pip
55
+ uv tool install 'sft-cli[torch]' # + .pt/.pth conversion
56
+ ```
57
+
58
+ ## 🚀 Quick start
59
+
60
+ ```bash
61
+ sft model.safetensors # open the interactive browser
62
+ sft info model.safetensors # one-shot summary
63
+ sft info model.safetensors --json # machine-readable
64
+ ```
65
+
66
+ The bare `sft <file>` form is a shortcut for `sft browse <file>`. Inside the browser: `↑↓` to navigate, `/` to filter, `L` on a LoRA file for LoRA Mode, `D` to diff against another file, `q` to quit.
67
+
68
+ ## What it does
69
+
70
+ **Inspect** a file without loading it:
71
+
72
+ ```bash
73
+ sft info model.safetensors # size, tensor count, dtypes, metadata
74
+ sft ls model.safetensors # flat list, sort/filter friendly
75
+ sft tree model.safetensors --depth=2 # hierarchical view
76
+ sft stat model.safetensors # per-tensor mean/std/min/max/sparsity
77
+ sft check model.safetensors # corruption + NaN/Inf scan
78
+ ```
79
+
80
+ **Compare** two checkpoints:
81
+
82
+ ```bash
83
+ sft diff base.safetensors finetuned.safetensors --delta \
84
+ --include='**.self_attn.**' # cosine, L2, max-abs per tensor
85
+ ```
86
+
87
+ **Edit** without writing Python:
88
+
89
+ ```bash
90
+ sft slice big.safetensors --include='**.weight' -o weights-only.safetensors
91
+ sft strip big.safetensors --exclude='*lora_*'
92
+ sft cast model.safetensors --dtype fp16
93
+ sft cat a.safetensors b.safetensors -o merged.safetensors
94
+ sft rename model.safetensors --sub 'model\.' 'backbone.'
95
+ sft split model.safetensors --max-size 4GB
96
+ sft convert pytorch_model.bin # → safetensors
97
+ ```
98
+
99
+ Every write command supports `--dry-run` and never overwrites the input — outputs default to `{stem}.{suffix}.safetensors`.
100
+
101
+ **Adapter workflows** (PEFT and Kohya):
102
+
103
+ ```bash
104
+ sft lora info adapter.safetensors # rank, alpha, target modules
105
+ sft lora svd adapter.safetensors # singular-value spectrum
106
+ sft lora compat base.safetensors adapter.safetensors
107
+ sft lora extract base.safetensors ft.safetensors --rank 16
108
+ sft lora resize adapter.safetensors --rank auto # per-pair adaptive rank
109
+ sft lora stack a.safetensors b.safetensors -a 0.7 -b 0.3
110
+ sft lora merge base.safetensors adapter.safetensors
111
+ sft lora convert adapter.safetensors --to peft # Kohya ↔ PEFT
112
+ ```
113
+
114
+ `--rank auto` picks each pair's output rank from its singular-value spectrum (`ceil(stable_rank) + 1`), so over-parameterized pairs compress harder than rich ones. `auto+N` adds a safety margin.
115
+
116
+ ## The browser
117
+
118
+ Press a key, get a result.
119
+
120
+ | Key | Action |
121
+ |:---:|---|
122
+ | `↑` `↓` | Navigate |
123
+ | `←` `→` | Collapse / expand tree |
124
+ | `Tab` | Switch between tree and table |
125
+ | `/` | Search / filter |
126
+ | `s` | Cycle sort |
127
+ | `Enter` | Tensor stats popup |
128
+ | `m` | File metadata |
129
+ | `c` | Cast file dtype |
130
+ | `L` | LoRA Mode (per-pair stats, SVD, compress) |
131
+ | `D` | Diff against another file |
132
+ | `:` | Command palette |
133
+ | `q` | Quit |
134
+
135
+ ## 🤖 AI agents
136
+
137
+ ```bash
138
+ sft skill install # auto-detects Claude / Cursor / Codex
139
+ sft skill status
140
+ sft skill uninstall
141
+ ```
142
+
143
+ The installer symlinks `sft`'s skill into your agent's well-known skills directory (`~/.claude/skills/sft`, `~/.cursor/skills/sft`, etc.), so it stays in sync when you `uv tool upgrade sft-cli`. Pass `--mode copy` for a frozen snapshot.
144
+
145
+ Every command supports `--json` for clean parsing:
146
+
147
+ ```bash
148
+ sft info model.safetensors --json | jq '.tensors'
149
+ sft lora info adapter.safetensors --json | jq '.rank'
150
+ sft stat model.safetensors --json --include='**.q_proj.*'
151
+ ```
152
+
153
+ ## License
154
+
155
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,121 @@
1
+ # sft
2
+
3
+ > The Swiss army knife for `.safetensors` files.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/sft-cli?style=flat-square)](https://pypi.org/project/sft-cli/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/sft-cli?style=flat-square)](https://pypi.org/project/sft-cli/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/matanby/sft-cli/ci.yml?branch=main&style=flat-square)](https://github.com/matanby/sft-cli/actions)
8
+ [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
9
+
10
+ <img src="https://vhs.charm.sh/vhs-3nFXGLuvC5swABYCewgchD.gif" alt="sft demo">
11
+
12
+ `sft` is a single-binary CLI for inspecting, editing, and diffing `.safetensors` files — and an interactive terminal browser for poking around large checkpoints. Most commands read the file header only, so multi-gigabyte models open in milliseconds.
13
+
14
+ It also ships a [skill](#-ai-agents) that teaches AI coding agents (Claude Code, Cursor, Codex CLI) when to reach for it and how to parse the output.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ uv tool install sft-cli # recommended
20
+ pip install sft-cli # or pip
21
+ uv tool install 'sft-cli[torch]' # + .pt/.pth conversion
22
+ ```
23
+
24
+ ## 🚀 Quick start
25
+
26
+ ```bash
27
+ sft model.safetensors # open the interactive browser
28
+ sft info model.safetensors # one-shot summary
29
+ sft info model.safetensors --json # machine-readable
30
+ ```
31
+
32
+ The bare `sft <file>` form is a shortcut for `sft browse <file>`. Inside the browser: `↑↓` to navigate, `/` to filter, `L` on a LoRA file for LoRA Mode, `D` to diff against another file, `q` to quit.
33
+
34
+ ## What it does
35
+
36
+ **Inspect** a file without loading it:
37
+
38
+ ```bash
39
+ sft info model.safetensors # size, tensor count, dtypes, metadata
40
+ sft ls model.safetensors # flat list, sort/filter friendly
41
+ sft tree model.safetensors --depth=2 # hierarchical view
42
+ sft stat model.safetensors # per-tensor mean/std/min/max/sparsity
43
+ sft check model.safetensors # corruption + NaN/Inf scan
44
+ ```
45
+
46
+ **Compare** two checkpoints:
47
+
48
+ ```bash
49
+ sft diff base.safetensors finetuned.safetensors --delta \
50
+ --include='**.self_attn.**' # cosine, L2, max-abs per tensor
51
+ ```
52
+
53
+ **Edit** without writing Python:
54
+
55
+ ```bash
56
+ sft slice big.safetensors --include='**.weight' -o weights-only.safetensors
57
+ sft strip big.safetensors --exclude='*lora_*'
58
+ sft cast model.safetensors --dtype fp16
59
+ sft cat a.safetensors b.safetensors -o merged.safetensors
60
+ sft rename model.safetensors --sub 'model\.' 'backbone.'
61
+ sft split model.safetensors --max-size 4GB
62
+ sft convert pytorch_model.bin # → safetensors
63
+ ```
64
+
65
+ Every write command supports `--dry-run` and never overwrites the input — outputs default to `{stem}.{suffix}.safetensors`.
66
+
67
+ **Adapter workflows** (PEFT and Kohya):
68
+
69
+ ```bash
70
+ sft lora info adapter.safetensors # rank, alpha, target modules
71
+ sft lora svd adapter.safetensors # singular-value spectrum
72
+ sft lora compat base.safetensors adapter.safetensors
73
+ sft lora extract base.safetensors ft.safetensors --rank 16
74
+ sft lora resize adapter.safetensors --rank auto # per-pair adaptive rank
75
+ sft lora stack a.safetensors b.safetensors -a 0.7 -b 0.3
76
+ sft lora merge base.safetensors adapter.safetensors
77
+ sft lora convert adapter.safetensors --to peft # Kohya ↔ PEFT
78
+ ```
79
+
80
+ `--rank auto` picks each pair's output rank from its singular-value spectrum (`ceil(stable_rank) + 1`), so over-parameterized pairs compress harder than rich ones. `auto+N` adds a safety margin.
81
+
82
+ ## The browser
83
+
84
+ Press a key, get a result.
85
+
86
+ | Key | Action |
87
+ |:---:|---|
88
+ | `↑` `↓` | Navigate |
89
+ | `←` `→` | Collapse / expand tree |
90
+ | `Tab` | Switch between tree and table |
91
+ | `/` | Search / filter |
92
+ | `s` | Cycle sort |
93
+ | `Enter` | Tensor stats popup |
94
+ | `m` | File metadata |
95
+ | `c` | Cast file dtype |
96
+ | `L` | LoRA Mode (per-pair stats, SVD, compress) |
97
+ | `D` | Diff against another file |
98
+ | `:` | Command palette |
99
+ | `q` | Quit |
100
+
101
+ ## 🤖 AI agents
102
+
103
+ ```bash
104
+ sft skill install # auto-detects Claude / Cursor / Codex
105
+ sft skill status
106
+ sft skill uninstall
107
+ ```
108
+
109
+ The installer symlinks `sft`'s skill into your agent's well-known skills directory (`~/.claude/skills/sft`, `~/.cursor/skills/sft`, etc.), so it stays in sync when you `uv tool upgrade sft-cli`. Pass `--mode copy` for a frozen snapshot.
110
+
111
+ Every command supports `--json` for clean parsing:
112
+
113
+ ```bash
114
+ sft info model.safetensors --json | jq '.tensors'
115
+ sft lora info adapter.safetensors --json | jq '.rank'
116
+ sft stat model.safetensors --json --include='**.q_proj.*'
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT — see [LICENSE](LICENSE).
@@ -20,6 +20,8 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.10",
21
21
  "Programming Language :: Python :: 3.11",
22
22
  "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
23
25
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
26
  "Topic :: Utilities",
25
27
  ]
@@ -1,3 +1,3 @@
1
1
  """sft — An interactive terminal browser for .safetensors files."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -740,6 +740,8 @@ class LoraSortMode(Enum):
740
740
  RANK_ASC = "rank ↑"
741
741
  EFF_RANK_DESC = "eff. rank ↓"
742
742
  EFF_RANK_ASC = "eff. rank ↑"
743
+ SV95_DESC = "sv95 ↓"
744
+ SV95_ASC = "sv95 ↑"
743
745
  NORM_A_DESC = "‖A‖ ↓"
744
746
  NORM_A_ASC = "‖A‖ ↑"
745
747
  NORM_B_DESC = "‖B‖ ↓"
@@ -753,6 +755,8 @@ LORA_SORT_ORDER = [
753
755
  LoraSortMode.RANK_ASC,
754
756
  LoraSortMode.EFF_RANK_DESC,
755
757
  LoraSortMode.EFF_RANK_ASC,
758
+ LoraSortMode.SV95_DESC,
759
+ LoraSortMode.SV95_ASC,
756
760
  LoraSortMode.NORM_A_DESC,
757
761
  LoraSortMode.NORM_A_ASC,
758
762
  LoraSortMode.NORM_B_DESC,
@@ -1295,6 +1299,7 @@ class LoraModeScreen(Screen):
1295
1299
  self.lora_info = lora_info # only populated for PEFT
1296
1300
  self._sort_idx = 0
1297
1301
  self._stats: dict[str, dict[str, float]] = {}
1302
+ self._tensor_cache: dict | None = None
1298
1303
 
1299
1304
  def compose(self) -> ComposeResult:
1300
1305
  yield Footer()
@@ -1331,12 +1336,26 @@ class LoraModeScreen(Screen):
1331
1336
  LoraSortMode.RANK_ASC: ("rank", " ↑"),
1332
1337
  LoraSortMode.EFF_RANK_DESC: ("eff_rank", " ↓"),
1333
1338
  LoraSortMode.EFF_RANK_ASC: ("eff_rank", " ↑"),
1339
+ LoraSortMode.SV95_DESC: ("sv95", " ↓"),
1340
+ LoraSortMode.SV95_ASC: ("sv95", " ↑"),
1334
1341
  LoraSortMode.NORM_A_DESC: ("norm_a", " ↓"),
1335
1342
  LoraSortMode.NORM_A_ASC: ("norm_a", " ↑"),
1336
1343
  LoraSortMode.NORM_B_DESC: ("norm_b", " ↓"),
1337
1344
  LoraSortMode.NORM_B_ASC: ("norm_b", " ↑"),
1338
1345
  }
1339
1346
 
1347
+ _LORA_COLUMN_SORT: dict[str, tuple[LoraSortMode, LoraSortMode]] = {
1348
+ "module": (LoraSortMode.MODULE_ASC, LoraSortMode.MODULE_DESC),
1349
+ "rank": (LoraSortMode.RANK_ASC, LoraSortMode.RANK_DESC),
1350
+ "eff_rank": (LoraSortMode.EFF_RANK_ASC, LoraSortMode.EFF_RANK_DESC),
1351
+ "sv95": (LoraSortMode.SV95_ASC, LoraSortMode.SV95_DESC),
1352
+ "norm_a": (LoraSortMode.NORM_A_ASC, LoraSortMode.NORM_A_DESC),
1353
+ "norm_b": (LoraSortMode.NORM_B_ASC, LoraSortMode.NORM_B_DESC),
1354
+ }
1355
+
1356
+ _SORT_MISSING_ASC = float("inf")
1357
+ _SORT_MISSING_DESC = float("-inf")
1358
+
1340
1359
  def on_mount(self) -> None:
1341
1360
  if self.lora_format == "peft" and self.lora_info:
1342
1361
  table = self.query_one("#lora-table", DataTable)
@@ -1363,6 +1382,8 @@ class LoraModeScreen(Screen):
1363
1382
  self._compute_stats()
1364
1383
  self._update_sort_indicator()
1365
1384
 
1385
+ _STATS_REFRESH_EVERY = 64
1386
+
1366
1387
  def _title_line(self) -> str:
1367
1388
  fmt_label = self.lora_format.upper()
1368
1389
  return (
@@ -1397,10 +1418,24 @@ class LoraModeScreen(Screen):
1397
1418
  "…",
1398
1419
  key=pair.module_key,
1399
1420
  )
1421
+ self._sync_module_column_width(table)
1422
+
1423
+ def _sync_module_column_width(self, table: DataTable) -> None:
1424
+ if self.lora_info is None:
1425
+ return
1426
+ max_len = max(
1427
+ (len(self._short_module_name(p)) for p in self.lora_info.pairs),
1428
+ default=16,
1429
+ )
1430
+ col = table.columns.get("module")
1431
+ if col is not None:
1432
+ col.content_width = max(col.content_width, max_len)
1433
+ table.refresh(layout=True)
1400
1434
 
1401
1435
  def _short_module_name(self, pair: LoRAPair) -> str:
1402
- parts = pair.module_key.split(".")
1403
- return ".".join(parts[-4:]) if len(parts) > 4 else pair.module_key
1436
+ from sft.ops.lora.detect import format_lora_module_display
1437
+
1438
+ return format_lora_module_display(pair.module_key)
1404
1439
 
1405
1440
  def _sorted_pairs(self) -> list[LoRAPair]:
1406
1441
  if self.lora_info is None:
@@ -1408,8 +1443,9 @@ class LoraModeScreen(Screen):
1408
1443
  mode = LORA_SORT_ORDER[self._sort_idx]
1409
1444
  pairs = list(self.lora_info.pairs)
1410
1445
 
1411
- def stat(name: str, key: str, default: float) -> float:
1412
- return self._stats.get(name, {}).get(key, default)
1446
+ def stat(name: str, key: str, *, ascending: bool) -> float:
1447
+ missing = self._SORT_MISSING_ASC if ascending else self._SORT_MISSING_DESC
1448
+ return self._stats.get(name, {}).get(key, missing)
1413
1449
 
1414
1450
  if mode == LoraSortMode.MODULE_ASC:
1415
1451
  pairs.sort(key=lambda p: natural_sort_key(p.module_key))
@@ -1422,42 +1458,56 @@ class LoraModeScreen(Screen):
1422
1458
  elif mode == LoraSortMode.EFF_RANK_DESC:
1423
1459
  pairs.sort(
1424
1460
  key=lambda p: (
1425
- -stat(p.module_key, "eff_rank", 0.0),
1461
+ -stat(p.module_key, "eff_rank", ascending=False),
1426
1462
  natural_sort_key(p.module_key),
1427
1463
  )
1428
1464
  )
1429
1465
  elif mode == LoraSortMode.EFF_RANK_ASC:
1430
1466
  pairs.sort(
1431
1467
  key=lambda p: (
1432
- stat(p.module_key, "eff_rank", 0.0),
1468
+ stat(p.module_key, "eff_rank", ascending=True),
1469
+ natural_sort_key(p.module_key),
1470
+ )
1471
+ )
1472
+ elif mode == LoraSortMode.SV95_DESC:
1473
+ pairs.sort(
1474
+ key=lambda p: (
1475
+ -stat(p.module_key, "sv95", ascending=False),
1476
+ natural_sort_key(p.module_key),
1477
+ )
1478
+ )
1479
+ elif mode == LoraSortMode.SV95_ASC:
1480
+ pairs.sort(
1481
+ key=lambda p: (
1482
+ stat(p.module_key, "sv95", ascending=True),
1433
1483
  natural_sort_key(p.module_key),
1434
1484
  )
1435
1485
  )
1436
1486
  elif mode == LoraSortMode.NORM_A_DESC:
1437
1487
  pairs.sort(
1438
1488
  key=lambda p: (
1439
- -stat(p.module_key, "norm_a", 0.0),
1489
+ -stat(p.module_key, "norm_a", ascending=False),
1440
1490
  natural_sort_key(p.module_key),
1441
1491
  )
1442
1492
  )
1443
1493
  elif mode == LoraSortMode.NORM_A_ASC:
1444
1494
  pairs.sort(
1445
1495
  key=lambda p: (
1446
- stat(p.module_key, "norm_a", 0.0),
1496
+ stat(p.module_key, "norm_a", ascending=True),
1447
1497
  natural_sort_key(p.module_key),
1448
1498
  )
1449
1499
  )
1450
1500
  elif mode == LoraSortMode.NORM_B_DESC:
1451
1501
  pairs.sort(
1452
1502
  key=lambda p: (
1453
- -stat(p.module_key, "norm_b", 0.0),
1503
+ -stat(p.module_key, "norm_b", ascending=False),
1454
1504
  natural_sort_key(p.module_key),
1455
1505
  )
1456
1506
  )
1457
1507
  elif mode == LoraSortMode.NORM_B_ASC:
1458
1508
  pairs.sort(
1459
1509
  key=lambda p: (
1460
- stat(p.module_key, "norm_b", 0.0),
1510
+ stat(p.module_key, "norm_b", ascending=True),
1461
1511
  natural_sort_key(p.module_key),
1462
1512
  )
1463
1513
  )
@@ -1509,6 +1559,7 @@ class LoraModeScreen(Screen):
1509
1559
  new_cursor = i
1510
1560
  if table.row_count > 0:
1511
1561
  table.move_cursor(row=new_cursor)
1562
+ self._sync_module_column_width(table)
1512
1563
  self._update_sort_indicator()
1513
1564
 
1514
1565
  @staticmethod
@@ -1541,17 +1592,20 @@ class LoraModeScreen(Screen):
1541
1592
  )
1542
1593
  return
1543
1594
 
1544
- for pair in self.lora_info.pairs:
1595
+ self._tensor_cache = tensors
1596
+ total = len(self.lora_info.pairs)
1597
+
1598
+ for i, pair in enumerate(self.lora_info.pairs):
1545
1599
  try:
1546
1600
  a = tensors[pair.lora_a_name]
1547
1601
  b = tensors[pair.lora_b_name]
1548
1602
 
1549
1603
  (s,) = _qr_svd(a, b, compute_uv=False)
1550
1604
  s_sq = s**2
1551
- total = float(s_sq.sum())
1552
- if total > 0:
1553
- eff_rank = total / float(s_sq.max())
1554
- cumvar = np.cumsum(s_sq) / total
1605
+ total_energy = float(s_sq.sum())
1606
+ if total_energy > 0:
1607
+ eff_rank = total_energy / float(s_sq.max())
1608
+ cumvar = np.cumsum(s_sq) / total_energy
1555
1609
  sv95 = int(np.searchsorted(cumvar, 0.95)) + 1
1556
1610
  else:
1557
1611
  eff_rank = 0.0
@@ -1569,19 +1623,43 @@ class LoraModeScreen(Screen):
1569
1623
  stats = {}
1570
1624
 
1571
1625
  self._stats[pair.module_key] = stats
1572
- self.app.call_from_thread(self._refresh_table)
1626
+ if (i + 1) % self._STATS_REFRESH_EVERY == 0 or (i + 1) == total:
1627
+ self.app.call_from_thread(self._refresh_table)
1573
1628
 
1574
1629
  # --- Actions ---
1575
1630
 
1576
1631
  def action_exit_mode(self) -> None:
1577
1632
  self.app.pop_screen()
1578
1633
 
1634
+ def _set_sort_mode(self, mode: LoraSortMode) -> None:
1635
+ if self.lora_format != "peft":
1636
+ return
1637
+ self._sort_idx = LORA_SORT_ORDER.index(mode)
1638
+ self._refresh_table()
1639
+
1579
1640
  def action_cycle_sort(self) -> None:
1580
1641
  if self.lora_format != "peft":
1581
1642
  return
1582
1643
  self._sort_idx = (self._sort_idx + 1) % len(LORA_SORT_ORDER)
1583
1644
  self._refresh_table()
1584
1645
 
1646
+ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
1647
+ """Click a column header to toggle asc/desc for that metric."""
1648
+ if self.lora_format != "peft" or event.control.id != "lora-table":
1649
+ return
1650
+ col_key = event.column_key.value
1651
+ modes = self._LORA_COLUMN_SORT.get(col_key)
1652
+ if modes is None:
1653
+ return
1654
+ asc, desc = modes
1655
+ current = LORA_SORT_ORDER[self._sort_idx]
1656
+ if current == asc:
1657
+ self._set_sort_mode(desc)
1658
+ elif current == desc:
1659
+ self._set_sort_mode(asc)
1660
+ else:
1661
+ self._set_sort_mode(desc if col_key != "module" else asc)
1662
+
1585
1663
  def on_data_table_row_selected(self, _event: DataTable.RowSelected) -> None:
1586
1664
  self.action_show_spectrum()
1587
1665
 
@@ -1785,8 +1863,9 @@ class LoraInfoScreen(ModalScreen):
1785
1863
 
1786
1864
  @staticmethod
1787
1865
  def _short_pair_name(pair: LoRAPair) -> str:
1788
- parts = pair.module_key.split(".")
1789
- return ".".join(parts[-4:]) if len(parts) > 4 else pair.module_key
1866
+ from sft.ops.lora.detect import format_lora_module_display
1867
+
1868
+ return format_lora_module_display(pair.module_key)
1790
1869
 
1791
1870
 
1792
1871
  class LoraResizePromptScreen(ModalScreen):
@@ -1961,8 +2040,11 @@ class SvdSpectrumScreen(ModalScreen):
1961
2040
  self.sv = [float(v) for v in singular_values]
1962
2041
 
1963
2042
  def compose(self) -> ComposeResult:
2043
+ from sft.ops.lora.detect import format_lora_module_display
2044
+
2045
+ module_label = format_lora_module_display(self.pair.module_key)
1964
2046
  with Container(id="svd-container"):
1965
- yield Label(f"SVD Spectrum — {self.pair.target_module}", id="svd-title")
2047
+ yield Label(f"SVD Spectrum — {module_label}", id="svd-title")
1966
2048
  yield Static(
1967
2049
  f"[dim]Module:[/dim] {self.pair.module_key}\n"
1968
2050
  f"[dim]Rank:[/dim] {self.pair.rank} "
@@ -115,6 +115,7 @@ def lora_info_cmd(
115
115
  """
116
116
  file = validate_safetensors(file)
117
117
 
118
+ from sft.ops.lora.detect import format_lora_module_display
118
119
  from sft.ops.lora.info import lora_info
119
120
 
120
121
  try:
@@ -153,7 +154,7 @@ def lora_info_cmd(
153
154
 
154
155
  typer.echo("Pairs:")
155
156
  for pair in info.pairs:
156
- module_short = pair.module_key.split(".")[-2] + "." + pair.target_module
157
+ module_short = format_lora_module_display(pair.module_key)
157
158
  a_shape = "x".join(str(d) for d in pair.lora_a_shape)
158
159
  b_shape = "x".join(str(d) for d in pair.lora_b_shape)
159
160
  typer.echo(
@@ -516,6 +517,7 @@ def lora_svd_cmd(
516
517
  "threshold": analysis.threshold,
517
518
  "modules": [
518
519
  {
520
+ "module_key": m.module_key,
519
521
  "module": m.module,
520
522
  "rank": m.rank,
521
523
  "sv_90": m.sv_90,
@@ -530,11 +532,16 @@ def lora_svd_cmd(
530
532
  typer.echo(json.dumps(data, indent=2))
531
533
  return
532
534
 
533
- header = f"{'Module':<16}{'Rank':>6}{'SV 90%':>10}{'SV 95%':>10}{'SV 99%':>10}{'Suggested rank':>16}"
535
+ col_width = max(16, *(len(m.module) for m in analysis.modules))
536
+ header = (
537
+ f"{'Module':<{col_width}}{'Rank':>6}{'SV 90%':>10}{'SV 95%':>10}"
538
+ f"{'SV 99%':>10}{'Suggested rank':>16}"
539
+ )
534
540
  typer.echo(header)
535
541
  for m in analysis.modules:
536
542
  typer.echo(
537
- f"{m.module:<16}{m.rank:>6}{m.sv_90:>10}{m.sv_95:>10}{m.sv_99:>10}{m.suggested_rank:>16}"
543
+ f"{m.module:<{col_width}}{m.rank:>6}{m.sv_90:>10}{m.sv_95:>10}"
544
+ f"{m.sv_99:>10}{m.suggested_rank:>16}"
538
545
  )
539
546
 
540
547
  typer.echo()