wize-dev-kit 0.26.1 → 0.27.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 (69) hide show
  1. package/ARCH.md +2 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.es.md +28 -6
  4. package/README.md +28 -6
  5. package/README.pt-BR.md +28 -6
  6. package/adapters/continue/render.js +2 -3
  7. package/adapters/copilot/render.js +3 -3
  8. package/adapters/cursor/render.js +2 -3
  9. package/adapters/generic/render.js +2 -3
  10. package/adapters/opencode/render.js +3 -4
  11. package/adapters/windsurf/render.js +2 -3
  12. package/docs/WORKTREES.md +150 -0
  13. package/docs/WORKTREES.pt-BR.md +150 -0
  14. package/docs/harnesses/antigravity.md +2 -0
  15. package/docs/harnesses/antigravity.pt-BR.md +2 -0
  16. package/docs/harnesses/claude-code.md +2 -0
  17. package/docs/harnesses/claude-code.pt-BR.md +2 -0
  18. package/docs/harnesses/codex.md +3 -1
  19. package/docs/harnesses/codex.pt-BR.md +3 -1
  20. package/docs/harnesses/continue.md +2 -0
  21. package/docs/harnesses/continue.pt-BR.md +2 -0
  22. package/docs/harnesses/copilot.md +2 -0
  23. package/docs/harnesses/copilot.pt-BR.md +2 -0
  24. package/docs/harnesses/cursor.md +2 -0
  25. package/docs/harnesses/cursor.pt-BR.md +2 -0
  26. package/docs/harnesses/generic.md +2 -0
  27. package/docs/harnesses/generic.pt-BR.md +2 -0
  28. package/docs/harnesses/hermes.md +2 -0
  29. package/docs/harnesses/hermes.pt-BR.md +2 -0
  30. package/docs/harnesses/kimi-code.md +2 -0
  31. package/docs/harnesses/kimi-code.pt-BR.md +2 -0
  32. package/docs/harnesses/kiro.md +2 -0
  33. package/docs/harnesses/kiro.pt-BR.md +2 -0
  34. package/docs/harnesses/opencode.md +2 -0
  35. package/docs/harnesses/opencode.pt-BR.md +2 -0
  36. package/docs/harnesses/windsurf.md +2 -0
  37. package/docs/harnesses/windsurf.pt-BR.md +2 -0
  38. package/package.json +1 -1
  39. package/src/orchestrator-skills/module.yaml +1 -1
  40. package/src/orchestrator-skills/wize/skill.md +17 -7
  41. package/src/orchestrator-skills/wize-help/skill.md +20 -7
  42. package/src/orchestrator-skills/wize-update/skill.md +33 -27
  43. package/tools/installer/commands/doctor.js +186 -40
  44. package/tools/installer/commands/document-project.js +13 -3
  45. package/tools/installer/commands/hooks.js +215 -0
  46. package/tools/installer/commands/hydrate.js +298 -0
  47. package/tools/installer/commands/sync.js +50 -23
  48. package/tools/installer/commands/update.js +596 -120
  49. package/tools/installer/commands/version-check.js +69 -10
  50. package/tools/installer/document-project/modes/full-rescan.js +1 -1
  51. package/tools/installer/document-project/modes/initial-scan.js +5 -4
  52. package/tools/installer/document-project/modes/quick.js +11 -4
  53. package/tools/installer/git-context.js +219 -0
  54. package/tools/installer/git-ops.js +211 -0
  55. package/tools/installer/install-plan.js +136 -0
  56. package/tools/installer/marker.js +67 -0
  57. package/tools/installer/project-root.js +68 -0
  58. package/tools/installer/project-status.js +171 -0
  59. package/tools/installer/render-shared.js +170 -4
  60. package/tools/installer/render-targets.js +112 -0
  61. package/tools/installer/semver.js +59 -0
  62. package/tools/installer/setup-helpers.js +89 -31
  63. package/tools/installer/templates/hydrate.sh +163 -0
  64. package/tools/installer/templates/wize-kit-update.yml +38 -0
  65. package/tools/installer/toml.js +133 -0
  66. package/tools/installer/util.js +16 -0
  67. package/tools/installer/version-check.js +167 -37
  68. package/tools/installer/wize-cli.js +216 -78
  69. package/tools/installer/sync.js +0 -29
@@ -0,0 +1,112 @@
1
+ // One runner for "render these adapters into this project", shared by
2
+ // install, update, sync and hydrate. Per-adapter failures are isolated, the
3
+ // write guard is always cleared, and counts come from render-shared's write
4
+ // stats rather than from each adapter's own return value.
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const shared = require('./render-shared.js');
11
+ const { ALIAS_BY_TARGET } = require('./setup-helpers.js');
12
+
13
+ // Where each adapter drops its `wize-*` entries. A configured target whose
14
+ // directory has no top-level `wize-` entry, or lacks its `wize` alias
15
+ // entrypoint, has not been rendered in this checkout, whatever else lives there.
16
+ const SENTINEL_DIRS = {
17
+ 'claude-code': '.claude/skills',
18
+ copilot: '.github/skills',
19
+ antigravity: '.agent/skills',
20
+ codex: '.agents/skills',
21
+ 'kimi-code': '.kimi/skills',
22
+ hermes: '.hermes/skills',
23
+ kiro: '.kiro/skills',
24
+ cursor: '.cursor/rules',
25
+ windsurf: '.windsurf/rules',
26
+ continue: '.continue/prompts',
27
+ opencode: '.opencode/agents',
28
+ generic: '.wize/agents'
29
+ };
30
+
31
+ // Directories an adapter writes besides its sentinel dir (personas and commands).
32
+ const EXTRA_OUTPUT_DIRS = {
33
+ copilot: ['.github/agents'],
34
+ opencode: ['.opencode/commands']
35
+ };
36
+
37
+ // Every path an adapter's render can overwrite: sentinel dir, extra dirs and aliases.
38
+ function outputSpecs(code) {
39
+ return [SENTINEL_DIRS[code], ...(EXTRA_OUTPUT_DIRS[code] || []), ...(ALIAS_BY_TARGET[code] || [])].filter(Boolean);
40
+ }
41
+
42
+ function hasSentinel(projectRoot, code) {
43
+ const rel = SENTINEL_DIRS[code];
44
+ if (!rel) return true; // unknown (custom) target: nothing to check
45
+ try {
46
+ if (!fs.readdirSync(path.join(projectRoot, rel)).some(n => n.startsWith('wize-'))) return false;
47
+ } catch {
48
+ return false;
49
+ }
50
+ return (ALIAS_BY_TARGET[code] || []).every(a => fs.existsSync(path.join(projectRoot, a)));
51
+ }
52
+
53
+ function missingTargets(projectRoot, targets) {
54
+ return (targets || []).map(t => (typeof t === 'string' ? t : t && t.code)).filter(c => c && !hasSentinel(projectRoot, c));
55
+ }
56
+
57
+ const codeOf = (x) => (typeof x === 'string' ? x : x && x.code);
58
+
59
+ function renderTargets({ kitRoot, projectRoot, targets, profiles, guard = null, probe = false, skipAgentsMd = false, log = () => {} }) {
60
+ const profileCodes = (profiles || ['core']).map(codeOf);
61
+ const adapters = [];
62
+ if (guard) shared.setWriteGuard(guard);
63
+ shared.setProbeMode(probe);
64
+ try {
65
+ for (const target of targets || []) {
66
+ const code = codeOf(target);
67
+ const entry = { code, written: 0, unchanged: 0, skippedTracked: [] };
68
+ adapters.push(entry);
69
+ const renderPath = path.join(kitRoot, 'adapters', code, 'render.js');
70
+ if (!fs.existsSync(renderPath)) {
71
+ entry.skipped = true;
72
+ entry.reason = 'adapter missing';
73
+ log(` - ${code}: skipped (adapter missing in this kit version)`);
74
+ continue;
75
+ }
76
+ delete require.cache[require.resolve(renderPath)]; // keep repeated runs in one process honest
77
+ shared.resetWriteStats();
78
+ try {
79
+ const mod = require(renderPath);
80
+ if (typeof mod.render !== 'function') {
81
+ entry.skipped = true;
82
+ entry.reason = 'no render() export';
83
+ log(` - ${code}: skipped (no render() export)`);
84
+ continue;
85
+ }
86
+ const out = mod.render(kitRoot, projectRoot, { profiles: profileCodes, skipAgentsMd });
87
+ if (!out || !Array.isArray(out.written)) {
88
+ entry.skipped = true;
89
+ entry.reason = 'stub (no skills emitted)';
90
+ log(` - ${code}: skipped (stub, no skills emitted)`);
91
+ continue;
92
+ }
93
+ const stats = shared.getWriteStats();
94
+ entry.written = stats.written;
95
+ entry.unchanged = stats.unchanged;
96
+ entry.skippedTracked = stats.skippedTracked;
97
+ const tracked = entry.skippedTracked.length ? `, ${entry.skippedTracked.length} tracked left alone` : '';
98
+ log(` ✓ ${code}: ${entry.written} written, ${entry.unchanged} unchanged${tracked}`);
99
+ } catch (err) {
100
+ entry.error = err.message;
101
+ log(` ✖ ${code}: ${err.message}`);
102
+ }
103
+ }
104
+ } finally {
105
+ shared.clearWriteGuard();
106
+ shared.setProbeMode(false);
107
+ shared.resetWriteStats();
108
+ }
109
+ return { ok: adapters.every(a => !a.error), adapters };
110
+ }
111
+
112
+ module.exports = { renderTargets, SENTINEL_DIRS, EXTRA_OUTPUT_DIRS, outputSpecs, missingTargets };
@@ -0,0 +1,59 @@
1
+ // Single semver implementation for the installer: parse, compare, greater.
2
+ // Prerelease-aware (0.27.0-rc.1 < 0.27.0); build metadata is ignored.
3
+
4
+ 'use strict';
5
+
6
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/;
7
+
8
+ // Returns { major, minor, patch, pre: [] } or null when `v` is not semver.
9
+ function parse(v) {
10
+ if (typeof v !== 'string') return null;
11
+ const m = SEMVER_RE.exec(v.trim().replace(/^v/i, ''));
12
+ if (!m) return null;
13
+ const pre = m[4] ? m[4].split('.').map(id => (/^\d+$/.test(id) ? Number(id) : id)) : [];
14
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre };
15
+ }
16
+
17
+ function comparePre(a, b) {
18
+ if (!a.length && !b.length) return 0;
19
+ if (!a.length) return 1; // a release outranks any prerelease
20
+ if (!b.length) return -1;
21
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
22
+ if (i >= a.length) return -1;
23
+ if (i >= b.length) return 1;
24
+ const x = a[i];
25
+ const y = b[i];
26
+ if (x === y) continue;
27
+ const xn = typeof x === 'number';
28
+ const yn = typeof y === 'number';
29
+ if (xn && yn) return x < y ? -1 : 1;
30
+ if (xn) return -1; // numeric identifiers sort below alphanumeric
31
+ if (yn) return 1;
32
+ return x < y ? -1 : 1;
33
+ }
34
+ return 0;
35
+ }
36
+
37
+ // -1 | 0 | 1. An unparseable argument counts as 0.0.0, so garbage never
38
+ // outranks a real version.
39
+ function compareVersions(a, b) {
40
+ const zero = { major: 0, minor: 0, patch: 0, pre: [] };
41
+ const pa = parse(a) || zero;
42
+ const pb = parse(b) || zero;
43
+ for (const k of ['major', 'minor', 'patch']) {
44
+ if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
45
+ }
46
+ return comparePre(pa.pre, pb.pre);
47
+ }
48
+
49
+ // A pin that is safe to put in a command line: plain semver, no `v` prefix,
50
+ // whitespace or build metadata (same rule as .wize/hooks/hydrate.sh).
51
+ const SAFE_PIN_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
52
+ const isSafePin = (v) => typeof v === 'string' && SAFE_PIN_RE.test(v);
53
+ const safePin = (v) => (isSafePin(v) ? v : null);
54
+
55
+ function semverGreater(a, b) {
56
+ return compareVersions(a, b) > 0;
57
+ }
58
+
59
+ module.exports = { parse, compareVersions, semverGreater, isSafePin, safePin };
@@ -12,6 +12,45 @@ const path = require('node:path');
12
12
  const GITIGNORE_BEGIN = '# >>> wize-dev-kit (managed) >>>';
13
13
  const GITIGNORE_END = '# <<< wize-dev-kit (managed) <<<';
14
14
 
15
+ // The 12 entrypoints for the `wize` alias skill/command each adapter emits,
16
+ // per adapter target. `wize-*` globs do not match them (no hyphen), so each gets
17
+ // an explicit line. Never widen to `wize*`: that would swallow user files such
18
+ // as wizebar.
19
+ const ALIAS_BY_TARGET = {
20
+ 'claude-code': ['.claude/skills/wize/'],
21
+ copilot: ['.github/skills/wize/'],
22
+ antigravity: ['.agent/skills/wize/'],
23
+ codex: ['.agents/skills/wize/'],
24
+ 'kimi-code': ['.kimi/skills/wize/'],
25
+ hermes: ['.hermes/skills/wize/'],
26
+ kiro: ['.kiro/skills/wize/'],
27
+ cursor: ['.cursor/rules/wize.mdc'],
28
+ windsurf: ['.windsurf/rules/wize.md'],
29
+ continue: ['.continue/prompts/wize.prompt'],
30
+ opencode: ['.opencode/commands/wize.md'],
31
+ generic: ['.wize/agents/wize.md']
32
+ };
33
+
34
+ const ALIAS_PATHS = Object.values(ALIAS_BY_TARGET).flat();
35
+
36
+ // Generated outputs: the `wize-*` glob per location plus its explicit alias line.
37
+ const GENERATED_PATHS = [
38
+ { dir: '.claude/skills/', glob: 'wize-*', alias: 'wize/' },
39
+ { dir: '.github/skills/', glob: 'wize-*', alias: 'wize/' },
40
+ { dir: '.github/agents/', glob: 'wize-*.agent.md', alias: null },
41
+ { dir: '.agent/skills/', glob: 'wize-*', alias: 'wize/' },
42
+ { dir: '.agents/skills/', glob: 'wize-*', alias: 'wize/' },
43
+ { dir: '.kimi/skills/', glob: 'wize-*', alias: 'wize/' },
44
+ { dir: '.hermes/skills/', glob: 'wize-*', alias: 'wize/' },
45
+ { dir: '.kiro/skills/', glob: 'wize-*', alias: 'wize/' },
46
+ { dir: '.cursor/rules/', glob: 'wize-*.mdc', alias: 'wize.mdc' },
47
+ { dir: '.windsurf/rules/', glob: 'wize-*.md', alias: 'wize.md' },
48
+ { dir: '.continue/prompts/', glob: 'wize-*.prompt', alias: 'wize.prompt' },
49
+ { dir: '.opencode/agents/', glob: 'wize-*.md', alias: null },
50
+ { dir: '.opencode/commands/', glob: 'wize-*.md', alias: 'wize.md' },
51
+ { dir: '.wize/agents/', glob: 'wize-*.md', alias: 'wize.md' }
52
+ ];
53
+
15
54
  const GITIGNORE_BODY = [
16
55
  '# Personal / per-developer (do NOT commit)',
17
56
  '.wize/config/user.toml',
@@ -19,27 +58,17 @@ const GITIGNORE_BODY = [
19
58
  '.wize/.local/',
20
59
  '.wize/implementation/quick-dev-log.md',
21
60
  '',
22
- '# Generated IDE adapter outputs (regenerate with `npx wize-dev-kit install`)',
23
- '.claude/skills/wize-*',
24
- '.github/skills/wize-*',
25
- '.github/agents/wize-*.agent.md',
26
- '.agent/skills/wize-*',
27
- '.agents/skills/wize-*',
28
- '.kimi/skills/wize-*',
29
- '.hermes/skills/wize-*',
30
- '.kiro/skills/wize-*',
31
- '.cursor/rules/wize-*.mdc',
32
- '.windsurf/rules/wize-*.md',
33
- '.continue/prompts/wize-*.prompt',
34
- '.opencode/agents/wize-*.md',
35
- '.opencode/commands/wize-*.md',
36
- '.wize/agents/wize-*.md'
61
+ '# Generated IDE adapter outputs (regenerate with `npx wize-dev-kit hydrate`)',
62
+ ...GENERATED_PATHS.flatMap(g => [g.dir + g.glob, ...(g.alias ? [g.dir + g.alias] : [])]),
63
+ '',
64
+ '# Worktrees created inside the repo (Claude Code)',
65
+ '.claude/worktrees/'
37
66
  ].join('\n');
38
67
 
39
68
  function buildGitignoreBlock() {
40
69
  return [
41
70
  GITIGNORE_BEGIN,
42
- '# Managed by `npx wize-dev-kit install`. Re-running install will update',
71
+ '# Managed by wize-dev-kit (install/update). Re-running either updates',
43
72
  '# the lines between the markers below; lines outside are untouched.',
44
73
  '',
45
74
  GITIGNORE_BODY,
@@ -47,35 +76,51 @@ function buildGitignoreBlock() {
47
76
  ].join('\n');
48
77
  }
49
78
 
50
- // Idempotent: if the block exists, replace it; otherwise append.
51
- // Returns { changed: boolean, mode: 'created'|'replaced'|'appended'|'unchanged' }.
52
- function applyGitignore(projectRoot, { dryRun = false } = {}) {
53
- const file = path.join(projectRoot, '.gitignore');
79
+ // Pure: the .gitignore text after the managed block is applied to `current`
80
+ // (null/'' for no file). Returns { next, changed, mode } with mode
81
+ // 'created'|'replaced'|'appended'|'unchanged'.
82
+ function computeGitignore(current) {
83
+ const exists = current !== null && current !== undefined;
84
+ const text = exists ? current : '';
54
85
  const block = buildGitignoreBlock();
55
- let current = '';
56
- let exists = fs.existsSync(file);
57
- if (exists) current = fs.readFileSync(file, 'utf-8');
58
-
59
- const startIdx = current.indexOf(GITIGNORE_BEGIN);
60
- const endIdx = current.indexOf(GITIGNORE_END);
86
+ const startIdx = text.indexOf(GITIGNORE_BEGIN);
87
+ const endIdx = text.indexOf(GITIGNORE_END);
61
88
  let next;
62
89
  let mode;
63
90
 
64
91
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
65
- const before = current.slice(0, startIdx).replace(/\n+$/, '');
66
- const after = current.slice(endIdx + GITIGNORE_END.length).replace(/^\n+/, '');
92
+ const before = text.slice(0, startIdx).replace(/\n+$/, '');
93
+ const after = text.slice(endIdx + GITIGNORE_END.length).replace(/^\n+/, '');
67
94
  next = [before, block, after].filter(Boolean).join('\n\n');
68
95
  if (!next.endsWith('\n')) next += '\n';
69
- mode = current.includes(block) ? 'unchanged' : 'replaced';
96
+ mode = text.includes(block) ? 'unchanged' : 'replaced';
70
97
  } else if (exists) {
71
- next = current.replace(/\n+$/, '') + '\n\n' + block + '\n';
98
+ next = text.replace(/\n+$/, '') + '\n\n' + block + '\n';
72
99
  mode = 'appended';
73
100
  } else {
74
101
  next = block + '\n';
75
102
  mode = 'created';
76
103
  }
104
+ return { next, changed: next !== text, mode };
105
+ }
106
+
107
+ // 'ok' | 'drift' | 'missing': whether the managed block in `current` (null
108
+ // for no file) is byte-for-byte what this kit would write.
109
+ function gitignoreBlockStatus(current) {
110
+ if (current === null || current === undefined) return 'missing';
111
+ const text = String(current).replace(/\r\n/g, '\n');
112
+ const startIdx = text.indexOf(GITIGNORE_BEGIN);
113
+ const endIdx = text.indexOf(GITIGNORE_END);
114
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return 'missing';
115
+ return text.slice(startIdx, endIdx + GITIGNORE_END.length) === buildGitignoreBlock() ? 'ok' : 'drift';
116
+ }
77
117
 
78
- const changed = next !== current;
118
+ // Idempotent: if the block exists, replace it; otherwise append.
119
+ // Returns { changed: boolean, mode: 'created'|'replaced'|'appended'|'unchanged' }.
120
+ function applyGitignore(projectRoot, { dryRun = false } = {}) {
121
+ const file = path.join(projectRoot, '.gitignore');
122
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null;
123
+ const { next, changed, mode } = computeGitignore(current);
79
124
  if (changed && !dryRun) fs.writeFileSync(file, next, 'utf-8');
80
125
  return { changed, mode };
81
126
  }
@@ -100,10 +145,23 @@ ${roleLine}
100
145
  `;
101
146
  }
102
147
 
148
+ // user.toml content for a checkout that has no copy to inherit: the name comes
149
+ // from `git config user.name`, then $USER, then "Developer".
150
+ function deriveUserToml(root, env = process.env) {
151
+ const { configUserName } = require('./git-context.js');
152
+ const name = configUserName(root) || env.USER || env.USERNAME || 'Developer';
153
+ return generateUserToml({ name });
154
+ }
155
+
103
156
  module.exports = {
157
+ deriveUserToml,
104
158
  GITIGNORE_BEGIN,
105
159
  GITIGNORE_END,
160
+ ALIAS_PATHS,
161
+ ALIAS_BY_TARGET,
106
162
  buildGitignoreBlock,
163
+ computeGitignore,
164
+ gitignoreBlockStatus,
107
165
  applyGitignore,
108
166
  generateUserToml
109
167
  };
@@ -0,0 +1,163 @@
1
+ #!/bin/sh
2
+ # wize-dev-kit managed hook v1
3
+ # Hydrates this checkout from .wize/config/project.toml without touching tracked
4
+ # files. `wize-dev-kit update` refreshes this file; local edits are overwritten.
5
+ # Always exits 0 so a failure never blocks worktree creation or a session start.
6
+ # Messages go to stderr: a Claude Code SessionStart hook adds stdout to the context.
7
+
8
+ # Same precedence as the CLI's resolveRoot: CLAUDE_PROJECT_DIR (the Claude session's
9
+ # own checkout), then the project found from the current directory (up to the git
10
+ # boundary), and only then ORCA_WORKTREE_PATH, which may be inherited from an Orca
11
+ # terminal of another worktree. The child hydrates this directory whatever else the
12
+ # environment says.
13
+ root=""
14
+ if [ -n "$CLAUDE_PROJECT_DIR" ]; then
15
+ root=$CLAUDE_PROJECT_DIR
16
+ else
17
+ # The walk ends at the first directory holding .git; with none above, only $PWD counts.
18
+ boundary=""
19
+ d=$PWD
20
+ while :; do
21
+ if [ -e "$d/.git" ]; then boundary=$d; break; fi
22
+ parent=$(dirname "$d")
23
+ [ "$parent" = "$d" ] && break
24
+ d=$parent
25
+ done
26
+ d=$PWD
27
+ while :; do
28
+ if [ -f "$d/.wize/config/project.toml" ]; then root=$d; break; fi
29
+ { [ -z "$boundary" ] || [ "$d" = "$boundary" ]; } && break
30
+ d=$(dirname "$d")
31
+ done
32
+ [ -n "$root" ] || root=${ORCA_WORKTREE_PATH:-$PWD}
33
+ fi
34
+ cd "$root" || exit 0
35
+ WIZE_PROJECT_ROOT=$PWD
36
+ export WIZE_PROJECT_ROOT
37
+ toml=.wize/config/project.toml
38
+ [ -f "$toml" ] || { echo "wize: no $toml here, skipping" >&2; exit 0; }
39
+
40
+ pin=$(sed -n 's/^kit_version *= *"\([^"]*\)".*/\1/p' "$toml" | head -1)
41
+ # Anchored: a glob would let "1.2.3.4:host/repo" through, which npm reads as a git spec.
42
+ printf '%s' "$pin" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || pin=""
43
+ pkg="wize-dev-kit@${pin:-latest}"
44
+
45
+ # Where the first configured target keeps its wize-* entries and its `wize`
46
+ # entrypoint (mirrors ALIAS_BY_TARGET in setup-helpers.js).
47
+ target=$(sed -n 's/^ide_targets *= *\[ *"\([^"]*\)".*/\1/p' "$toml" | head -1)
48
+ case "${target:-claude-code}" in
49
+ claude-code) dir=.claude/skills; alias=.claude/skills/wize ;;
50
+ copilot) dir=.github/skills; alias=.github/skills/wize ;;
51
+ antigravity) dir=.agent/skills; alias=.agent/skills/wize ;;
52
+ codex) dir=.agents/skills; alias=.agents/skills/wize ;;
53
+ kimi-code) dir=.kimi/skills; alias=.kimi/skills/wize ;;
54
+ hermes) dir=.hermes/skills; alias=.hermes/skills/wize ;;
55
+ kiro) dir=.kiro/skills; alias=.kiro/skills/wize ;;
56
+ cursor) dir=.cursor/rules; alias=.cursor/rules/wize.mdc ;;
57
+ windsurf) dir=.windsurf/rules; alias=.windsurf/rules/wize.md ;;
58
+ continue) dir=.continue/prompts; alias=.continue/prompts/wize.prompt ;;
59
+ opencode) dir=.opencode/agents; alias=.opencode/commands/wize.md ;;
60
+ generic) dir=.wize/agents; alias=.wize/agents/wize.md ;;
61
+ *) dir=""; alias="" ;;
62
+ esac
63
+
64
+ # True when `dir` holds wize-* entries and the `wize` entrypoint exists.
65
+ has_wize() {
66
+ [ -n "$dir" ] && [ -n "$alias" ] && [ -e "$alias" ] || return 1
67
+ for f in "$dir"/wize-*; do [ -e "$f" ] && return 0; done
68
+ return 1
69
+ }
70
+
71
+ # hydrate ships in 0.27.0; older pins only know sync.
72
+ legacy=0
73
+ if [ -n "$pin" ] && [ "$(printf '%s\n' 0.27.0 "$pin" | sort -V | head -1)" != 0.27.0 ]; then legacy=1; fi
74
+ # sort -V ranks 0.27.0-rc.1 after 0.27.0; semver ranks it before.
75
+ case "$pin" in 0.27.0-*) legacy=1 ;; esac
76
+
77
+ # The pin a marker records: its "pin" when present, else "kit_version".
78
+ marker=.wize/.local/hydrated.json
79
+ marker_matches() {
80
+ [ -f "$marker" ] || return 1
81
+ # A fallback render (the pinned kit could not be run) is current for an hour only.
82
+ if grep -q '"fallback": *true' "$marker" 2>/dev/null && [ -z "$(find "$marker" -mmin -60 2>/dev/null)" ]; then return 1; fi
83
+ if grep -q '"pin": *"' "$marker" 2>/dev/null; then
84
+ grep -Fq "\"pin\": \"$pin\"" "$marker"
85
+ else
86
+ grep -Fq "\"kit_version\": \"$pin\"" "$marker"
87
+ fi
88
+ }
89
+
90
+ # Fast path: already hydrated for this pin. No npx, no network. A legacy pin's
91
+ # own `sync` writes no marker and no user.toml, and an exact pin is immutable,
92
+ # so its files being present is enough (a marker, when there is one, must match).
93
+ if [ -n "$pin" ] && has_wize; then
94
+ if [ "$legacy" = 1 ]; then
95
+ if [ ! -f "$marker" ] || marker_matches; then exit 0; fi
96
+ elif [ -f .wize/config/user.toml ] && marker_matches; then
97
+ exit 0
98
+ fi
99
+ fi
100
+
101
+ # user.toml is per developer and git-ignored: copy it from the primary checkout.
102
+ common_dir() { (cd "$1" 2>/dev/null && d=$(git rev-parse --git-common-dir 2>/dev/null) && cd "$d" 2>/dev/null && pwd -P); }
103
+ primary=""
104
+ if [ -n "$ORCA_ROOT_PATH" ] && [ -n "$(common_dir .)" ] && [ "$(common_dir "$ORCA_ROOT_PATH")" = "$(common_dir .)" ]; then
105
+ primary=$ORCA_ROOT_PATH
106
+ fi
107
+ [ -n "$primary" ] || primary=$(git worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')
108
+ if [ ! -f .wize/config/user.toml ] && [ -n "$primary" ] && [ -f "$primary/.wize/config/user.toml" ]; then
109
+ cp "$primary/.wize/config/user.toml" .wize/config/user.toml && echo "wize: user.toml copied from $primary" >&2
110
+ fi
111
+
112
+ if [ "$legacy" = 1 ]; then
113
+ cmd=sync
114
+ quiet=""
115
+ else
116
+ cmd=hydrate
117
+ quiet="--quiet"
118
+ fi
119
+
120
+ # Offline, npx spends about 70 s revalidating the packument even for a cached
121
+ # version, which would outlast the 180 s SessionStart timeout over 3 attempts.
122
+ # An exact pin is immutable, so use the cache as is; npm's own retries are off
123
+ # because this loop retries, and each attempt is capped by `timeout` when it
124
+ # exists. Without `timeout`, npm's fetch timeout (20 s per request) bounds a
125
+ # refused or slow answer, but not a registry that silently drops packets.
126
+ offline=""
127
+ [ -n "$pin" ] && offline="--prefer-offline"
128
+ cap=""
129
+ command -v timeout >/dev/null 2>&1 && cap="timeout 55"
130
+
131
+ backoff="${WIZE_HOOK_BACKOFF_S:-2}"
132
+ errf=$(mktemp 2>/dev/null) || errf=""
133
+ ok=0
134
+ i=1
135
+ while [ "$i" -le 3 ]; do
136
+ # shellcheck disable=SC2086
137
+ if [ -n "$errf" ]; then
138
+ WIZE_SKIP_BASELINE=1 npm_config_fetch_retries=0 npm_config_fetch_timeout=20000 $cap npx --yes $offline "$pkg" "$cmd" $quiet >&2 2>"$errf"
139
+ rc=$?
140
+ cat "$errf" >&2
141
+ else
142
+ WIZE_SKIP_BASELINE=1 npm_config_fetch_retries=0 npm_config_fetch_timeout=20000 $cap npx --yes $offline "$pkg" "$cmd" $quiet >&2
143
+ rc=$?
144
+ fi
145
+ if [ "$rc" = 0 ]; then ok=1; break; fi
146
+ # ETARGET / notarget: the pin is not on the registry; retrying gets the same answer.
147
+ if [ -n "$errf" ] && grep -Eq 'ETARGET|notarget' "$errf" 2>/dev/null; then break; fi
148
+ # 124: `timeout` killed the attempt (a network that drops packets); retrying only waits again.
149
+ [ "$rc" = 124 ] && break
150
+ [ "$i" -lt 3 ] && sleep $((i * backoff))
151
+ i=$((i + 1))
152
+ done
153
+ [ -z "$errf" ] || rm -f "$errf"
154
+ [ "$ok" = 1 ] || echo "wize: $cmd failed; run: npx $pkg $cmd" >&2
155
+
156
+ # hydrate prints its own update notice; older pins get one from the registry.
157
+ if [ "$cmd" = sync ] && [ "$WIZE_DISABLE_UPDATE_CHECK" != 1 ] && command -v npm >/dev/null 2>&1; then
158
+ if command -v timeout >/dev/null 2>&1; then latest=$(timeout 5 npm view wize-dev-kit version 2>/dev/null); else latest=""; fi
159
+ if [ -n "$latest" ] && [ "$latest" != "$pin" ]; then
160
+ echo "wize: project pinned to $pin, latest is $latest (update on the default branch: npx wize-dev-kit@latest update)" >&2
161
+ fi
162
+ fi
163
+ exit 0
@@ -0,0 +1,38 @@
1
+ # Managed by `wize-dev-kit hooks install --github-action`; safe to edit.
2
+ # Opens a pull request that bumps the wize-dev-kit pin once a week.
3
+ #
4
+ # Pull requests created with GITHUB_TOKEN do not trigger other workflows, so
5
+ # required checks will not run on them until someone pushes to the branch.
6
+ # The repository setting "Allow GitHub Actions to create and approve pull
7
+ # requests" (Settings > Actions > General) must be enabled.
8
+ name: Wize kit update
9
+
10
+ on:
11
+ schedule:
12
+ - cron: '17 6 * * 1'
13
+ workflow_dispatch:
14
+
15
+ permissions:
16
+ contents: write
17
+ pull-requests: write
18
+
19
+ jobs:
20
+ update:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0
26
+ - uses: actions/setup-node@v4
27
+ with:
28
+ node-version: 20
29
+ - name: Set git identity
30
+ run: |
31
+ git config user.name "github-actions[bot]"
32
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
33
+ - name: Prepare the kit update PR
34
+ env:
35
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36
+ BASE_BRANCH: ${{ github.event.repository.default_branch }}
37
+ # A fresh checkout has no origin/HEAD, so name the default branch explicitly.
38
+ run: npx --yes wize-dev-kit@latest update --pr --base "origin/$BASE_BRANCH"
@@ -0,0 +1,133 @@
1
+ // Minimal TOML reader/writer for the subset the kit emits: `[section]`
2
+ // headers, `key = "value"` scalars and `key = ["a", "b"]` string arrays.
3
+ // The writer is surgical: it edits one string value in place and verifies the
4
+ // result, so a caller can never report a rewrite that did not happen.
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+
11
+ // Drops a trailing `# comment`, but only when the `#` sits outside a string.
12
+ function stripComment(line) {
13
+ let quote = null;
14
+ for (let i = 0; i < line.length; i++) {
15
+ const ch = line[i];
16
+ if (quote === '"') {
17
+ if (ch === '\\') i++;
18
+ else if (ch === '"') quote = null;
19
+ } else if (quote === "'") {
20
+ if (ch === "'") quote = null;
21
+ } else if (ch === '"' || ch === "'") {
22
+ quote = ch;
23
+ } else if (ch === '#') {
24
+ return line.slice(0, i);
25
+ }
26
+ }
27
+ return line;
28
+ }
29
+
30
+ function parseTomlValue(raw) {
31
+ if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
32
+ if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
33
+ if (raw.startsWith('[') && raw.endsWith(']')) {
34
+ return raw.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
35
+ }
36
+ if (raw === 'true') return true;
37
+ if (raw === 'false') return false;
38
+ if (!isNaN(Number(raw))) return Number(raw);
39
+ return raw;
40
+ }
41
+
42
+ function parseToml(content) {
43
+ const out = {};
44
+ let section = null;
45
+ for (const raw of content.split('\n')) {
46
+ const line = stripComment(raw).trim();
47
+ if (!line) continue;
48
+ const head = line.match(/^\[([^\]]+)\]$/);
49
+ if (head) { section = head[1]; out[section] = out[section] || {}; continue; }
50
+ const kv = line.match(/^([a-zA-Z_][\w-]*)\s*=\s*(.+)$/);
51
+ if (!kv) continue;
52
+ const [, key, valRaw] = kv;
53
+ const v = parseTomlValue(valRaw.trim());
54
+ if (section) out[section][key] = v;
55
+ else out[key] = v;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function readToml(file) {
61
+ let content;
62
+ try { content = fs.readFileSync(file, 'utf-8'); } catch { return {}; }
63
+ return parseToml(content);
64
+ }
65
+
66
+ function loadProjectConfig(projectRoot) {
67
+ return readToml(path.join(projectRoot, '.wize/config/project.toml'));
68
+ }
69
+
70
+ function escapeRe(s) {
71
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ }
73
+
74
+ // Pure rewrite of `key = "value"` inside `[section]`: keeps a trailing
75
+ // comment, inserts the key right after the header when absent. Returns the new
76
+ // text (identical to `content` when nothing changes), or null when the value
77
+ // is unsafe to write, the section is missing, the key holds a non-string
78
+ // value, or the result does not parse back to the new value.
79
+ function rewriteTomlValue(content, section, key, value) {
80
+ const val = String(value);
81
+ if (/["\\\r\n]/.test(val)) return null;
82
+
83
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
84
+ const lines = content.split('\n');
85
+ const headRe = new RegExp('^\\s*\\[' + escapeRe(section) + '\\]\\s*(#.*)?\\r?$');
86
+ const start = lines.findIndex(l => headRe.test(l));
87
+ if (start === -1) return null;
88
+ let end = lines.length;
89
+ for (let i = start + 1; i < lines.length; i++) {
90
+ if (/^\s*\[/.test(lines[i])) { end = i; break; }
91
+ }
92
+
93
+ const keyRe = new RegExp('^\\s*' + escapeRe(key) + '\\s*=');
94
+ const strRe = new RegExp('^(\\s*' + escapeRe(key) + '\\s*=\\s*)"[^"]*"(\\s*(#.*)?)$');
95
+ let idx = -1;
96
+ for (let i = start + 1; i < end; i++) {
97
+ if (keyRe.test(lines[i])) { idx = i; break; }
98
+ }
99
+
100
+ if (idx === -1) {
101
+ lines.splice(start + 1, 0, `${key} = "${val}"` + (eol === '\r\n' ? '\r' : ''));
102
+ } else {
103
+ if (!strRe.test(lines[idx])) return null;
104
+ lines[idx] = lines[idx].replace(strRe, (_, pre, post) => `${pre}"${val}"${post}`);
105
+ }
106
+
107
+ const next = lines.join('\n');
108
+ const parsed = parseToml(next);
109
+ return parsed[section] && parsed[section][key] === val ? next : null;
110
+ }
111
+
112
+ // Sets `key = "value"` inside `[section]` on disk. Returns false when the
113
+ // rewrite is refused or the re-read file does not carry the new value.
114
+ function setTomlValue(file, section, key, value) {
115
+ let content;
116
+ try { content = fs.readFileSync(file, 'utf-8'); } catch { return false; }
117
+ const next = rewriteTomlValue(content, section, key, value);
118
+ if (next === null) return false;
119
+ if (next !== content) {
120
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
121
+ try {
122
+ fs.writeFileSync(tmp, next, 'utf-8');
123
+ fs.renameSync(tmp, file);
124
+ } catch {
125
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
126
+ return false;
127
+ }
128
+ }
129
+ const parsed = readToml(file);
130
+ return !!(parsed[section] && parsed[section][key] === String(value));
131
+ }
132
+
133
+ module.exports = { readToml, parseToml, loadProjectConfig, rewriteTomlValue, setTomlValue };