sshlg-skills 1.63.0 → 1.63.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.
- package/CHANGELOG.md +30 -0
- package/bin/sshlg-skills.js +12 -1
- package/lib/claude-pinned.js +17 -5
- package/lib/claude-plugin-source.js +17 -2
- package/lib/json-spans.js +23 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
## v1.63.2 — the Claude transaction keeps settings in Claude Code's format
|
|
2
|
+
|
|
3
|
+
- Replacing a marketplace source in `~/.claude/settings.json` wrote the new object on one
|
|
4
|
+
line inside a 2-space file. The same `update` then refused to wire hooks ("not in the
|
|
5
|
+
2-space JSON format Claude Code writes"). A replaced object or array now keeps the
|
|
6
|
+
layout of its line: pretty inside a multi-line object, compact inside a single-line one.
|
|
7
|
+
Inserting a source — no marketplace map yet, an empty map, a new member or an empty
|
|
8
|
+
member entry — writes the missing branch in the file's own layout too.
|
|
9
|
+
- Found by the 1.63.1 installed update on a real profile: the native transaction verified
|
|
10
|
+
its three members, then hook wiring degraded. Running `update` again with 1.63.2
|
|
11
|
+
rewrites those sources in the file's own layout and wires hooks.
|
|
12
|
+
|
|
13
|
+
## v1.63.1 — Claude update works on hosts with live sessions
|
|
14
|
+
|
|
15
|
+
- `update` no longer refuses a Claude member whose installed payload already equals its
|
|
16
|
+
pin. Such a member (same version, a docs-only newer source commit, or extra
|
|
17
|
+
project/local scopes) is reported as payload-equivalent and left installed: no
|
|
18
|
+
uninstall or reinstall rewrites a payload directory that running sessions are using.
|
|
19
|
+
Members with different bytes keep the repair/update contract and its refusals.
|
|
20
|
+
- Python bytecode caches (`__pycache__/*.pyc`) written beside hook scripts are runtime
|
|
21
|
+
artifacts, not payload; payload readback ignores them and still refuses any other
|
|
22
|
+
file in such a directory.
|
|
23
|
+
- Known limits (B-191, B-192): a payload-equivalent member keeps its upstream marketplace
|
|
24
|
+
source, so a later native update follows upstream rather than the pin; and a member
|
|
25
|
+
installed in several scopes still blocks the Claude update once its version changes.
|
|
26
|
+
A `.pyc` is ignored only beside its pinned `.py`, and bytecode stays outside readback.
|
|
27
|
+
- Found by the installed readback of 1.63.0 on a real profile: 1.63.0's preview
|
|
28
|
+
refused on agent-sync's bytecode cache, then on members installed in more than one
|
|
29
|
+
scope. No settings or payload were changed by those refused previews.
|
|
30
|
+
|
|
1
31
|
## v1.63.0 — native update safety and managed instruction previews
|
|
2
32
|
|
|
3
33
|
- Claude Code installation now uses exact member commits through its native plugin
|
package/bin/sshlg-skills.js
CHANGED
|
@@ -407,7 +407,8 @@ function withClaudePlan(steps, f) {
|
|
|
407
407
|
steps.unshift(opres.step('claude-pins', 'home',
|
|
408
408
|
'native immutable source transaction; protected catalogs/settings, scoped repair, rollback on failure, payload readback',
|
|
409
409
|
{ fingerprint: native.fingerprint, profile: native.profile,
|
|
410
|
-
members: native.jobs.map(j => `${j.pluginInstall}: ${j.action} @ ${j.row.commit}; ${j.row.files.length} files`)
|
|
410
|
+
members: native.jobs.map(j => `${j.pluginInstall}: ${j.action} @ ${j.row.commit}; ${j.row.files.length} files`)
|
|
411
|
+
.concat(native.equivalent.map(e => `${e.pluginInstall}: payload-equivalent, left installed (installed ${String(e.installed).slice(0, 7)}, pin ${e.pin.slice(0, 7)}; ${e.files} files equal)`)),
|
|
411
412
|
journal: path.join(native.transactionRoot, 'latest.json') }));
|
|
412
413
|
}
|
|
413
414
|
const result = attachWaitPlan(steps, f);
|
|
@@ -420,6 +421,16 @@ function runClaudePlan(planned, f) {
|
|
|
420
421
|
log(`\n== Updating Claude Code plugins ==\n skipped: ${claudeScope(f).skippedBy} (the Claude host is outside this run's scope)`);
|
|
421
422
|
return true;
|
|
422
423
|
}
|
|
424
|
+
for (const e of saved.native.equivalent) log(` ${e.member || e.name}: payload equals pin ${e.pin.slice(0, 7)} (${e.files} files); installed source ${String(e.installed).slice(0, 7)} kept and its marketplace source is unchanged, so a later native update follows that source, not the pin`);
|
|
425
|
+
if (!saved.native.jobs.length) {
|
|
426
|
+
// Nothing is written; still describe the state at apply time, not at preview.
|
|
427
|
+
let again;
|
|
428
|
+
try { again = require('../lib/claude-pinned').prepare(SKILLS, saved.inventory, { home: os.homedir(), profileRoot: claudeRoot() }); }
|
|
429
|
+
catch (e) { log(` Claude native transaction refused before execution: ${e.message}`); return false; }
|
|
430
|
+
if (again.fingerprint !== saved.native.fingerprint) { log(' Claude native state changed since the preview; run update again'); return false; }
|
|
431
|
+
log(' Claude native transaction: not needed — every member already carries its pinned payload');
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
423
434
|
// Admission, version check and all backups happen before any other channel.
|
|
424
435
|
let result;
|
|
425
436
|
try { result = require('../lib/claude-pin-transaction').execute(saved.native, SKILLS, saved.inventory); }
|
package/lib/claude-pinned.js
CHANGED
|
@@ -34,7 +34,8 @@ function editAt(text,keys,value) {
|
|
|
34
34
|
const parsed=J.parse(text);let node=parsed.root;
|
|
35
35
|
for(let i=0;i<keys.length-1;i++) {
|
|
36
36
|
let entry=node.props && node.props.find(p=>p.key===keys[i]);
|
|
37
|
-
|
|
37
|
+
// Insert the whole missing branch in one edit so it takes the file's layout.
|
|
38
|
+
if(!entry) return J.edit(text,node,keys[i],keys.slice(i+1).reduceRight((v,k)=>({[k]:v}),value));
|
|
38
39
|
if(!entry.value.props) throw Error('native Claude settings field is not an object');
|
|
39
40
|
node=entry.value;
|
|
40
41
|
}
|
|
@@ -61,13 +62,24 @@ function prepare(members,inventory,opts) {
|
|
|
61
62
|
const transactionRoot=path.join(home,'.sshlg-skills/claude-pinned',Source.sha256(profile).slice(0,16),'transactions');
|
|
62
63
|
const active=read(path.join(transactionRoot,'latest.json'),'null').data;
|
|
63
64
|
if(fs.existsSync(path.join(transactionRoot,'lock')) || (active && (active.status==='running' || active.status==='failed' && (!active.settingsRestored || active.uncertain)))) throw Error('incomplete Claude transaction; inspect '+path.join(transactionRoot,'latest.json'));
|
|
64
|
-
const jobs=[];
|
|
65
|
+
const jobs=[],equivalent=[];
|
|
65
66
|
for(const row of rows) {
|
|
66
67
|
const marketplace=row.pluginInstall.split('@')[1],dir=path.join(home,'.sshlg-skills/claude-pinned',Source.sha256(profile).slice(0,16),marketplace);
|
|
67
68
|
const declaration=settings.data.extraKnownMarketplaces?.[marketplace];
|
|
68
69
|
if(declaration && (!plain(declaration) || !ownedSource(declaration.source,row,dir))) throw Error(row.name+': conflicting marketplace declaration');
|
|
69
70
|
if(known.data[marketplace] && !ownedSource(known.data[marketplace].source,row,dir)) throw Error(row.name+': conflicting known marketplace source');
|
|
70
71
|
const existing=registry.data.plugins[row.pluginInstall];
|
|
72
|
+
// Same version, pinned bytes already installed: nothing to deliver. A different
|
|
73
|
+
// (docs-only) source commit or extra project/local scopes need no native mutation,
|
|
74
|
+
// and a reinstall would rewrite a payload directory live sessions are using.
|
|
75
|
+
const user=Array.isArray(existing)?existing.filter(r=>r&&r.scope==='user'):[];
|
|
76
|
+
if(user.length===1 && user[0].version===row.version && typeof user[0].installPath==='string'
|
|
77
|
+
&& !(existing.length===1 && user[0].gitCommitSha===row.commit)
|
|
78
|
+
&& Bindings.profilePath(user[0].installPath).startsWith(path.join(profile,'plugins/cache')+path.sep)
|
|
79
|
+
&& existing.every(r=>r && r.installPath===user[0].installPath && r.version===row.version)) {
|
|
80
|
+
let equal=false;try {equal=Source.verifyPayload(user[0].installPath,row)>=1;}catch(_) {}
|
|
81
|
+
if(equal) {equivalent.push({name:row.name,pluginInstall:row.pluginInstall,version:row.version,pin:row.commit,installed:user[0].gitCommitSha||null,scopes:existing.length,files:row.files.length});continue;}
|
|
82
|
+
}
|
|
71
83
|
if(existing!==undefined && (!Array.isArray(existing) || existing.length!==1 || existing[0]?.scope!=='user')) throw Error(row.name+': native immutable repair supports one user-scope install only');
|
|
72
84
|
for(const id of Object.keys(registry.data.plugins)) if(id.endsWith('@'+marketplace) && id!==row.pluginInstall) throw Error(row.name+': marketplace has another installed plugin');
|
|
73
85
|
let action='install',old=null,oldFiles=null,repairEligible=false;
|
|
@@ -81,7 +93,7 @@ function prepare(members,inventory,opts) {
|
|
|
81
93
|
if(!manifest.exists || manifest.data?.name!==row.pluginInstall.split('@')[0]) throw Error(row.name+': unknown installed manifest');
|
|
82
94
|
let exact=false;try {exact=old.version===row.version && old.gitCommitSha===row.commit && Source.verifyPayload(old.installPath,row)>=1;}catch(_) {}
|
|
83
95
|
action=exact?'verify':old.version===row.version?'reinstall':'update';
|
|
84
|
-
oldFiles=Source.snapshotPayload(old.installPath);
|
|
96
|
+
oldFiles=Source.snapshotPayload(old.installPath,'present');
|
|
85
97
|
if(action==='reinstall' && !/^[a-f0-9]{40}$/.test(old.gitCommitSha||'')) throw Error(row.name+': repair requires prior immutable Git identity');
|
|
86
98
|
if(action==='reinstall' && (oldFiles.length!==row.files.length || oldFiles.some(f=>!row.files.some(p=>p.path===f.path)))) throw Error(row.name+': unknown same-version payload files; preserve before repair');
|
|
87
99
|
repairEligible=!(row.configSurface || configured(manifest.data) || oldFiles.some(f=>/\.(mcpb|dxt)$/i.test(f.path)) || Object.prototype.hasOwnProperty.call(settings.data.pluginConfigs||{},row.pluginInstall));
|
|
@@ -98,7 +110,7 @@ function prepare(members,inventory,opts) {
|
|
|
98
110
|
}
|
|
99
111
|
jobs.push({name:row.name,pluginInstall:row.pluginInstall,marketplace,dir,file,catalog,action,row,old,oldFiles,repairEligible});
|
|
100
112
|
}
|
|
101
|
-
const fingerprint=Source.sha256(JSON.stringify({settings:settings.text,registry:registry.text,known:known.text,catalogs:jobs.map(j=>({text:read(j.file,'null').text,action:j.action,oldFiles:j.oldFiles})),inventory:rows,profile,home,bindings:[settingsFile,registryFile,knownFile,...jobs.map(j=>j.file)].map(Bindings.profilePath)}));
|
|
102
|
-
return {fingerprint,requested,profile,home,settingsFile,registryFile,knownFile,settings,registry,known,jobs,transactionRoot};
|
|
113
|
+
const fingerprint=Source.sha256(JSON.stringify({equivalent,settings:settings.text,registry:registry.text,known:known.text,catalogs:jobs.map(j=>({text:read(j.file,'null').text,action:j.action,oldFiles:j.oldFiles})),inventory:rows,profile,home,bindings:[settingsFile,registryFile,knownFile,...jobs.map(j=>j.file)].map(Bindings.profilePath)}));
|
|
114
|
+
return {fingerprint,requested,profile,home,settingsFile,registryFile,knownFile,settings,registry,known,jobs,equivalent,transactionRoot};
|
|
103
115
|
}
|
|
104
116
|
module.exports={prepare,editAt,ownedSource,configured,parseMetadata};
|
|
@@ -73,7 +73,13 @@ function catalog(row) {
|
|
|
73
73
|
return {name:row.pluginInstall.split('@')[1],owner:{name:'ssheleg'},plugins:[{
|
|
74
74
|
...row.entry,name:row.pluginInstall.split('@')[0],source:{source:'git-subdir',url:'https://github.com/'+row.repo+'.git',path:row.pluginPath,sha:row.commit}}]};
|
|
75
75
|
}
|
|
76
|
-
function snapshotPayload(root) {
|
|
76
|
+
function snapshotPayload(root,pinned) {
|
|
77
|
+
// `pinned`: the set of pinned paths. A bytecode cache is ignored only for a .pyc whose
|
|
78
|
+
// source sits pinned beside it; it stays outside this readback either way.
|
|
79
|
+
// `pinned === 'present'`: an installed payload being replaced, where a .pyc may
|
|
80
|
+
// belong to its own (old) .py rather than to the new pin.
|
|
81
|
+
const hasSource=pinned==='present'?rel=>{try {return fs.lstatSync(path.join(root,rel)).isFile();} catch(_) {return false;}}
|
|
82
|
+
:(s=>rel=>s.has(rel))(pinned instanceof Set?pinned:new Set());
|
|
77
83
|
const files=[];
|
|
78
84
|
function walk(dir,rel='') {
|
|
79
85
|
for(const e of fs.readdirSync(dir,{withFileTypes:true})) {
|
|
@@ -82,6 +88,15 @@ function snapshotPayload(root) {
|
|
|
82
88
|
if(!e.isDirectory() || fs.readdirSync(full,{withFileTypes:true}).some(x=>!x.isFile() || !/^[0-9]+$/.test(x.name))) throw Error('unexpected native lease-marker shape');
|
|
83
89
|
continue;
|
|
84
90
|
}
|
|
91
|
+
// Python writes bytecode caches beside scripts that hooks run; they are
|
|
92
|
+
// regenerated runtime artifacts, never pinned payload. Anything else there refuses.
|
|
93
|
+
if(e.name==='__pycache__' && e.isDirectory()) {
|
|
94
|
+
for(const x of fs.readdirSync(full,{withFileTypes:true})) {
|
|
95
|
+
const m=/^([^.]+)\.[A-Za-z0-9_-]+(?:\.opt-\d+)?\.pyc$/.exec(x.name);
|
|
96
|
+
if(!x.isFile() || !m || !hasSource((rel?rel+'/':'')+m[1]+'.py')) throw Error('unexpected bytecode-cache shape: '+name+'/'+x.name);
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
85
100
|
if(e.isDirectory()) walk(full,name);
|
|
86
101
|
else if(e.isFile()) files.push(name);
|
|
87
102
|
else throw Error('non-regular installed plugin payload');
|
|
@@ -92,7 +107,7 @@ function snapshotPayload(root) {
|
|
|
92
107
|
return files.sort().map(file=>{const b=fs.readFileSync(path.join(root,file));return {path:file,sha256:sha256(b),bytes:b.length};});
|
|
93
108
|
}
|
|
94
109
|
function verifyPayload(root,row) {
|
|
95
|
-
const
|
|
110
|
+
const expected=new Map(row.files.map(f=>[f.path,f])),actual=snapshotPayload(root,new Set(expected.keys()));
|
|
96
111
|
if(actual.length!==expected.size || actual.some(f=>!expected.has(f.path))) throw Error('installed plugin file inventory differs from pin');
|
|
97
112
|
for(const f of actual) {const e=expected.get(f.path);if(f.bytes!==e.bytes || f.sha256!==e.sha256) throw Error('installed plugin bytes differ from pin: '+f.path);}
|
|
98
113
|
return actual.length;
|
package/lib/json-spans.js
CHANGED
|
@@ -53,7 +53,18 @@ function edit(text, object, key, value) {
|
|
|
53
53
|
if (!object || !object.props) throw new Error('target is not an object');
|
|
54
54
|
const ps = object.props, index = ps.findIndex(p => p.key === key), p = ps[index];
|
|
55
55
|
const splice = (start, end, insert = '') => text.slice(0, start) + insert + text.slice(end);
|
|
56
|
-
if (p && value !== undefined)
|
|
56
|
+
if (p && value !== undefined) {
|
|
57
|
+
// A replaced object or array keeps the layout of its line: pretty in a multi-line
|
|
58
|
+
// object (the format Claude Code itself writes), compact in a single-line one.
|
|
59
|
+
const nl = text.includes('\r\n') ? '\r\n' : '\n';
|
|
60
|
+
const lineStart = text.lastIndexOf('\n', p.start - 1) + 1;
|
|
61
|
+
const lead = text.slice(lineStart, p.start);
|
|
62
|
+
const multiline = /[\r\n]/.test(text.slice(object.start + 1, ps[0].start)) && /^[ \t]*$/.test(lead);
|
|
63
|
+
const unit = lead.includes('\t') ? '\t' : 2;
|
|
64
|
+
const encoded = multiline && value !== null && typeof value === 'object'
|
|
65
|
+
? JSON.stringify(value, null, unit).replace(/\n/g, nl + lead) : JSON.stringify(value);
|
|
66
|
+
return splice(p.value.start, p.value.end, encoded);
|
|
67
|
+
}
|
|
57
68
|
if (p) {
|
|
58
69
|
if (ps.length === 1) {
|
|
59
70
|
// Insertion into an empty object precedes its original whitespace.
|
|
@@ -72,7 +83,17 @@ function edit(text, object, key, value) {
|
|
|
72
83
|
const encoded = multiline ? JSON.stringify(value, null, 2).replace(/\n/g, newline + indent) : JSON.stringify(value);
|
|
73
84
|
const member = JSON.stringify(key) + colon + encoded;
|
|
74
85
|
if (ps.length) return splice(ps[ps.length - 1].end, ps[ps.length - 1].end, ',' + (multiline ? newline + indent : '') + member);
|
|
75
|
-
// Empty object has no foreign property bytes to alter.
|
|
86
|
+
// Empty object has no foreign property bytes to alter. In a multi-line document an
|
|
87
|
+
// empty `{}` that starts its own line opens onto lines of its own, one level deeper,
|
|
88
|
+
// which is the layout Claude Code writes; otherwise its existing gap is kept.
|
|
89
|
+
const lineStart = text.lastIndexOf('\n', object.start - 1) + 1;
|
|
90
|
+
const before = text.slice(lineStart, object.start);
|
|
91
|
+
const opens = /\n/.test(text) && text.slice(object.start, object.end) === '{}' && /^[ \t]*(?:"(?:[^"\\]|\\.)*"[ \t]*:[ \t]*)?$/.test(before);
|
|
92
|
+
if (opens) {
|
|
93
|
+
const base = before.match(/^[ \t]*/)[0], step = base.includes('\t') ? '\t' : ' ', inner = base + step;
|
|
94
|
+
const pretty = JSON.stringify(key) + ': ' + JSON.stringify(value, null, step === '\t' ? '\t' : 2).replace(/\n/g, newline + inner);
|
|
95
|
+
return splice(object.start + 1, object.start + 1, newline + inner + pretty + newline + base);
|
|
96
|
+
}
|
|
76
97
|
return splice(object.start + 1, object.start + 1, member);
|
|
77
98
|
}
|
|
78
99
|
|
package/package.json
CHANGED