@nemus-cli/nemus 0.2.1
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 +86 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/assets/README.md +53 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +66 -0
- package/assets/favicon-32.png +0 -0
- package/assets/favicon-64.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon.svg +23 -0
- package/assets/logo-256.png +0 -0
- package/assets/logo-512.png +0 -0
- package/assets/logo-wordmark.svg +35 -0
- package/assets/logo.svg +32 -0
- package/bin/workspace.js +130 -0
- package/dist/cli/ai-prompt.js +421 -0
- package/dist/cli/dashboard/AgentList.js +61 -0
- package/dist/cli/dashboard/DashboardSidebar.js +280 -0
- package/dist/cli/dashboard/HelpFooter.js +73 -0
- package/dist/cli/dashboard/StatusBadge.js +50 -0
- package/dist/cli/dashboard/index.js +11 -0
- package/dist/cli/dashboard/types.js +2 -0
- package/dist/cli/tui.js +48 -0
- package/dist/commands/ai-prompt.js +22 -0
- package/dist/commands/analyze-deps.js +138 -0
- package/dist/commands/archive.js +113 -0
- package/dist/commands/branch/create.js +119 -0
- package/dist/commands/branch/index.js +104 -0
- package/dist/commands/branch/merge.js +90 -0
- package/dist/commands/branch/rebase.js +84 -0
- package/dist/commands/branch/switch.js +165 -0
- package/dist/commands/cache/index.js +80 -0
- package/dist/commands/cache/manager.js +198 -0
- package/dist/commands/cleanup.js +153 -0
- package/dist/commands/configure-claude.js +55 -0
- package/dist/commands/configure.js +231 -0
- package/dist/commands/create.js +204 -0
- package/dist/commands/dashboard/hook-handler.js +162 -0
- package/dist/commands/dashboard/index.js +258 -0
- package/dist/commands/dashboard/launcher.js +40 -0
- package/dist/commands/dashboard/session-picker.js +106 -0
- package/dist/commands/dashboard/sidebar.js +39 -0
- package/dist/commands/dashboard/workspace-picker.js +95 -0
- package/dist/commands/delete.js +194 -0
- package/dist/commands/deprecated-aliases.js +44 -0
- package/dist/commands/diff.js +129 -0
- package/dist/commands/doctor.js +141 -0
- package/dist/commands/generate-docs.js +77 -0
- package/dist/commands/ghq-status.js +72 -0
- package/dist/commands/go.js +154 -0
- package/dist/commands/history.js +124 -0
- package/dist/commands/list.js +191 -0
- package/dist/commands/mcp/index.js +69 -0
- package/dist/commands/migrate.js +296 -0
- package/dist/commands/remove-repo.js +217 -0
- package/dist/commands/report-bug.js +112 -0
- package/dist/commands/run.js +147 -0
- package/dist/commands/save-context.js +171 -0
- package/dist/commands/sessions.js +129 -0
- package/dist/commands/status.js +138 -0
- package/dist/commands/suite/create.js +212 -0
- package/dist/commands/suite/delete.js +64 -0
- package/dist/commands/suite/export.js +126 -0
- package/dist/commands/suite/import.js +128 -0
- package/dist/commands/suite/index.js +94 -0
- package/dist/commands/suite/list.js +49 -0
- package/dist/commands/suite/use.js +227 -0
- package/dist/commands/sync.js +117 -0
- package/dist/commands/update.js +203 -0
- package/dist/mcp/install.js +550 -0
- package/dist/mcp/server.js +398 -0
- package/dist/mcp/tools.js +922 -0
- package/dist/pi-extensions/permission-sync.ts.template +41 -0
- package/dist/pi-extensions/permissions-gate.ts.template +69 -0
- package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/dist/pi-extensions/review-reminder.ts.template +58 -0
- package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
- package/dist/program.js +136 -0
- package/dist/scripts/workspace-table.py +394 -0
- package/dist/types/dashboard.js +11 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/agent-config.js +263 -0
- package/dist/utils/agent-state.js +178 -0
- package/dist/utils/banner.js +24 -0
- package/dist/utils/branch-operations.js +232 -0
- package/dist/utils/bug-report.js +355 -0
- package/dist/utils/cache.js +168 -0
- package/dist/utils/claude-integration.js +617 -0
- package/dist/utils/claude-sessions.js +260 -0
- package/dist/utils/cleanup-operations.js +120 -0
- package/dist/utils/colors.js +26 -0
- package/dist/utils/command-helpers.js +44 -0
- package/dist/utils/config.js +150 -0
- package/dist/utils/context-file.js +33 -0
- package/dist/utils/dashboard-hooks.js +172 -0
- package/dist/utils/dependency-analyzer.js +234 -0
- package/dist/utils/diff-operations.js +80 -0
- package/dist/utils/doc-generator.js +184 -0
- package/dist/utils/ghq-integration.js +332 -0
- package/dist/utils/git-operations.js +237 -0
- package/dist/utils/git-status.js +187 -0
- package/dist/utils/github.js +68 -0
- package/dist/utils/health-checks.js +345 -0
- package/dist/utils/history.js +150 -0
- package/dist/utils/hooks.js +100 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/permission-sync.js +681 -0
- package/dist/utils/pi-extensions.js +224 -0
- package/dist/utils/progress.js +92 -0
- package/dist/utils/prompts.js +279 -0
- package/dist/utils/repo-resolver.js +260 -0
- package/dist/utils/retry.js +65 -0
- package/dist/utils/run-operations.js +30 -0
- package/dist/utils/suite.js +159 -0
- package/dist/utils/sync-operations.js +107 -0
- package/dist/utils/tmux-dashboard.js +184 -0
- package/dist/utils/validation.js +153 -0
- package/dist/utils/version-check.js +121 -0
- package/dist/utils/workspace-meta.js +185 -0
- package/install-shell-integration.sh +532 -0
- package/install.sh +401 -0
- package/package.json +90 -0
- package/remove-shell-block.awk +44 -0
- package/scripts/postinstall.js +85 -0
- package/skills/analyze-deps.md +23 -0
- package/skills/archive-workspace.md +26 -0
- package/skills/branch-create.md +18 -0
- package/skills/create-workspace.md +39 -0
- package/skills/delete-workspace.md +37 -0
- package/skills/list-org-repos.md +26 -0
- package/skills/list-suites.md +24 -0
- package/skills/list-workspaces.md +27 -0
- package/skills/refresh-workspace-docs.md +89 -0
- package/skills/remove-repo.md +25 -0
- package/skills/run-command.md +23 -0
- package/skills/save-context.md +68 -0
- package/skills/search-repos.md +31 -0
- package/skills/snapshot-list.md +18 -0
- package/skills/snapshot-save.md +12 -0
- package/skills/switch-branch.md +18 -0
- package/skills/update-cache.md +21 -0
- package/skills/update-workspace.md +66 -0
- package/skills/workspace-cleanup.md +18 -0
- package/skills/workspace-diff.md +23 -0
- package/skills/workspace-doctor.md +23 -0
- package/skills/workspace-info.md +45 -0
- package/skills/workspace-manager/SKILL.md +201 -0
- package/skills/workspace-manager/references/ai-prompt.md +44 -0
- package/skills/workspace-manager/references/analyze-deps.md +45 -0
- package/skills/workspace-manager/references/archive-workspace.md +38 -0
- package/skills/workspace-manager/references/branch-create.md +31 -0
- package/skills/workspace-manager/references/branch-merge.md +22 -0
- package/skills/workspace-manager/references/branch-rebase.md +22 -0
- package/skills/workspace-manager/references/cache-clear.md +18 -0
- package/skills/workspace-manager/references/cache-info.md +22 -0
- package/skills/workspace-manager/references/configure-claude.md +31 -0
- package/skills/workspace-manager/references/configure.md +35 -0
- package/skills/workspace-manager/references/create-workspace.md +45 -0
- package/skills/workspace-manager/references/dashboard.md +61 -0
- package/skills/workspace-manager/references/delete-workspace.md +33 -0
- package/skills/workspace-manager/references/generate-docs.md +36 -0
- package/skills/workspace-manager/references/ghq-status.md +28 -0
- package/skills/workspace-manager/references/go.md +33 -0
- package/skills/workspace-manager/references/history.md +41 -0
- package/skills/workspace-manager/references/list-org-repos.md +26 -0
- package/skills/workspace-manager/references/list-suites.md +21 -0
- package/skills/workspace-manager/references/list-workspaces.md +27 -0
- package/skills/workspace-manager/references/mcp.md +49 -0
- package/skills/workspace-manager/references/remove-repo.md +26 -0
- package/skills/workspace-manager/references/run-command.md +44 -0
- package/skills/workspace-manager/references/search-repos.md +24 -0
- package/skills/workspace-manager/references/sessions.md +27 -0
- package/skills/workspace-manager/references/suite-create.md +22 -0
- package/skills/workspace-manager/references/suite-delete.md +21 -0
- package/skills/workspace-manager/references/suite-export.md +19 -0
- package/skills/workspace-manager/references/suite-import.md +19 -0
- package/skills/workspace-manager/references/suite-use.md +27 -0
- package/skills/workspace-manager/references/switch-branch.md +25 -0
- package/skills/workspace-manager/references/update-cache.md +22 -0
- package/skills/workspace-manager/references/update-workspace.md +50 -0
- package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
- package/skills/workspace-manager/references/workspace-diff.md +27 -0
- package/skills/workspace-manager/references/workspace-doctor.md +27 -0
- package/skills/workspace-manager/references/workspace-info.md +33 -0
- package/skills/workspace-manager/references/workspace-status.md +43 -0
- package/skills/workspace-manager/references/workspace-sync.md +26 -0
- package/skills/workspace-status.md +26 -0
- package/skills/workspace-sync.md +23 -0
- package/src/cli/ai-prompt.test.ts +667 -0
- package/src/cli/ai-prompt.ts +421 -0
- package/src/cli/dashboard/AgentList.tsx +44 -0
- package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
- package/src/cli/dashboard/HelpFooter.tsx +22 -0
- package/src/cli/dashboard/StatusBadge.tsx +23 -0
- package/src/cli/dashboard/index.ts +5 -0
- package/src/cli/dashboard/types.ts +16 -0
- package/src/cli/tui.tsx +51 -0
- package/src/cli/workspace-cli.test.ts +30 -0
- package/src/commands/ai-prompt.ts +21 -0
- package/src/commands/analyze-deps.ts +119 -0
- package/src/commands/archive.ts +114 -0
- package/src/commands/branch/create.ts +94 -0
- package/src/commands/branch/index.ts +74 -0
- package/src/commands/branch/merge.ts +61 -0
- package/src/commands/branch/rebase.ts +54 -0
- package/src/commands/branch/switch.ts +150 -0
- package/src/commands/cache/index.ts +51 -0
- package/src/commands/cache/manager.ts +168 -0
- package/src/commands/cleanup.ts +132 -0
- package/src/commands/configure-claude.ts +55 -0
- package/src/commands/configure.ts +204 -0
- package/src/commands/create.ts +196 -0
- package/src/commands/dashboard/hook-handler.ts +138 -0
- package/src/commands/dashboard/index.ts +248 -0
- package/src/commands/dashboard/launcher.ts +42 -0
- package/src/commands/dashboard/session-picker.ts +73 -0
- package/src/commands/dashboard/sidebar.js +39 -0
- package/src/commands/dashboard/workspace-picker.ts +62 -0
- package/src/commands/delete.test.ts +250 -0
- package/src/commands/delete.ts +171 -0
- package/src/commands/deprecated-aliases.ts +55 -0
- package/src/commands/diff.ts +106 -0
- package/src/commands/doctor.ts +110 -0
- package/src/commands/generate-docs.ts +45 -0
- package/src/commands/ghq-status.ts +70 -0
- package/src/commands/go.ts +125 -0
- package/src/commands/history.ts +136 -0
- package/src/commands/list.test.ts +219 -0
- package/src/commands/list.ts +176 -0
- package/src/commands/mcp/index.ts +39 -0
- package/src/commands/migrate.ts +277 -0
- package/src/commands/remove-repo.ts +194 -0
- package/src/commands/report-bug.ts +88 -0
- package/src/commands/run.ts +125 -0
- package/src/commands/save-context.test.ts +113 -0
- package/src/commands/save-context.ts +141 -0
- package/src/commands/sessions.ts +99 -0
- package/src/commands/status.ts +111 -0
- package/src/commands/suite/create.ts +240 -0
- package/src/commands/suite/delete.ts +65 -0
- package/src/commands/suite/export.ts +97 -0
- package/src/commands/suite/import.ts +100 -0
- package/src/commands/suite/index.ts +66 -0
- package/src/commands/suite/list.ts +54 -0
- package/src/commands/suite/use.ts +232 -0
- package/src/commands/sync.ts +97 -0
- package/src/commands/update.ts +197 -0
- package/src/mcp/install.ts +529 -0
- package/src/mcp/server.ts +579 -0
- package/src/mcp/tools.test.ts +545 -0
- package/src/mcp/tools.ts +1019 -0
- package/src/mcp/update-workspace.test.ts +220 -0
- package/src/pi-extensions/permission-sync.ts.template +41 -0
- package/src/pi-extensions/permissions-gate.ts.template +69 -0
- package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/src/pi-extensions/review-reminder.ts.template +58 -0
- package/src/pi-extensions/workspace-input-status.ts.template +919 -0
- package/src/program.ts +112 -0
- package/src/scripts/workspace-table.py +394 -0
- package/src/types/dashboard.ts +29 -0
- package/src/types/index.ts +109 -0
- package/src/types/ink.d.ts +171 -0
- package/src/utils/agent-config.test.ts +303 -0
- package/src/utils/agent-config.ts +264 -0
- package/src/utils/agent-state.test.ts +166 -0
- package/src/utils/agent-state.ts +135 -0
- package/src/utils/banner.ts +24 -0
- package/src/utils/branch-operations.ts +237 -0
- package/src/utils/bug-report.test.ts +174 -0
- package/src/utils/bug-report.ts +355 -0
- package/src/utils/cache.ts +150 -0
- package/src/utils/claude-integration.test.ts +381 -0
- package/src/utils/claude-integration.ts +649 -0
- package/src/utils/claude-sessions.test.ts +262 -0
- package/src/utils/claude-sessions.ts +251 -0
- package/src/utils/cleanup-operations.ts +94 -0
- package/src/utils/colors.ts +25 -0
- package/src/utils/command-helpers.ts +46 -0
- package/src/utils/config.test.ts +203 -0
- package/src/utils/config.ts +133 -0
- package/src/utils/context-file.ts +36 -0
- package/src/utils/dashboard-hooks.test.ts +168 -0
- package/src/utils/dashboard-hooks.ts +163 -0
- package/src/utils/dependency-analyzer.ts +243 -0
- package/src/utils/diff-operations.ts +59 -0
- package/src/utils/doc-generator.ts +187 -0
- package/src/utils/ghq-integration.test.ts +195 -0
- package/src/utils/ghq-integration.ts +317 -0
- package/src/utils/git-operations.test.ts +265 -0
- package/src/utils/git-operations.ts +233 -0
- package/src/utils/git-status.ts +171 -0
- package/src/utils/github.ts +74 -0
- package/src/utils/health-checks.ts +359 -0
- package/src/utils/history.ts +140 -0
- package/src/utils/hooks.ts +92 -0
- package/src/utils/logger.ts +37 -0
- package/src/utils/permission-sync.test.ts +593 -0
- package/src/utils/permission-sync.ts +720 -0
- package/src/utils/pi-extensions.test.ts +182 -0
- package/src/utils/pi-extensions.ts +226 -0
- package/src/utils/progress.ts +73 -0
- package/src/utils/prompts.test.ts +169 -0
- package/src/utils/prompts.ts +296 -0
- package/src/utils/repo-resolver.test.ts +313 -0
- package/src/utils/repo-resolver.ts +294 -0
- package/src/utils/retry.test.ts +124 -0
- package/src/utils/retry.ts +85 -0
- package/src/utils/run-operations.ts +38 -0
- package/src/utils/suite.test.ts +256 -0
- package/src/utils/suite.ts +144 -0
- package/src/utils/sync-operations.ts +87 -0
- package/src/utils/tmux-dashboard.ts +193 -0
- package/src/utils/validation.test.ts +139 -0
- package/src/utils/validation.ts +128 -0
- package/src/utils/version-check.test.ts +108 -0
- package/src/utils/version-check.ts +96 -0
- package/src/utils/workspace-meta.ts +173 -0
- package/sync-permissions.sh +137 -0
- package/tsconfig.json +20 -0
- package/uninstall.sh +162 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import { logSuccess, logInfo, logWarning } from './logger';
|
|
5
|
+
import { getSkillsTargetDirs, getAgentPaths } from './agent-config';
|
|
6
|
+
|
|
7
|
+
function getDefaultClaudeSettingsPath(): string {
|
|
8
|
+
return path.join(os.homedir(), '.claude', 'settings.json');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns the shell command used for the Stop hook entry.
|
|
13
|
+
* Resolves to the sync-permissions.sh script bundled with this package.
|
|
14
|
+
*/
|
|
15
|
+
export function getPermissionSyncHookCommand(): string {
|
|
16
|
+
// __dirname is dist/utils/ or src/utils/ — either way, two levels up is project root
|
|
17
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'sync-permissions.sh');
|
|
18
|
+
|
|
19
|
+
if (fs.existsSync(scriptPath)) {
|
|
20
|
+
return `bash "${scriptPath}"`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
throw new Error(
|
|
24
|
+
'Could not find sync-permissions.sh. Make sure the package is installed correctly.'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ClaudeHookCommand {
|
|
29
|
+
type: 'command';
|
|
30
|
+
command: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ClaudeHookMatcherEntry {
|
|
34
|
+
matcher?: string;
|
|
35
|
+
hooks: ClaudeHookCommand[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ClaudeHooksConfig {
|
|
39
|
+
[event: string]: ClaudeHookMatcherEntry[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ClaudeSettings {
|
|
43
|
+
hooks?: ClaudeHooksConfig;
|
|
44
|
+
[key: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readClaudeSettings(settingsPath: string): ClaudeSettings {
|
|
48
|
+
if (!fs.existsSync(settingsPath)) {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const content = fs.readFileSync(settingsPath, 'utf-8');
|
|
53
|
+
return JSON.parse(content) as ClaudeSettings;
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeClaudeSettings(settingsPath: string, settings: ClaudeSettings): void {
|
|
60
|
+
const dir = path.dirname(settingsPath);
|
|
61
|
+
if (!fs.existsSync(dir)) {
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
const tmp = settingsPath + '.tmp';
|
|
65
|
+
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
66
|
+
fs.renameSync(tmp, settingsPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matcherEntryContainsCommand(entry: ClaudeHookMatcherEntry, needle: string): boolean {
|
|
70
|
+
return entry.hooks?.some((h) => h.command.includes(needle)) ?? false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getCommandFromMatcherEntry(entry: ClaudeHookMatcherEntry): string | undefined {
|
|
74
|
+
return entry.hooks?.[0]?.command;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Install the permission-sync Stop hook into ~/.claude/settings.json.
|
|
79
|
+
* Idempotent — if a sync-permissions hook already exists with the same command,
|
|
80
|
+
* it's left as-is. If it exists with a different path (stale from a previous
|
|
81
|
+
* install location), the command is updated in place.
|
|
82
|
+
*
|
|
83
|
+
* Uses the Claude Code hooks format:
|
|
84
|
+
* { hooks: [{ type: "command", command: "..." }] }
|
|
85
|
+
*
|
|
86
|
+
* @param settingsPath - Override the settings file path (for testing).
|
|
87
|
+
*/
|
|
88
|
+
export function installPermissionSyncHook(settingsPath?: string): void {
|
|
89
|
+
const filePath = settingsPath ?? getDefaultClaudeSettingsPath();
|
|
90
|
+
|
|
91
|
+
let command: string;
|
|
92
|
+
try {
|
|
93
|
+
command = getPermissionSyncHookCommand();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logWarning(
|
|
96
|
+
`Could not locate sync-permissions.sh — skipping hook installation. ${
|
|
97
|
+
err instanceof Error ? err.message : ''
|
|
98
|
+
}`
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const settings = readClaudeSettings(filePath);
|
|
104
|
+
|
|
105
|
+
if (!settings.hooks) {
|
|
106
|
+
settings.hooks = {};
|
|
107
|
+
}
|
|
108
|
+
if (!settings.hooks.Stop) {
|
|
109
|
+
settings.hooks.Stop = [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const existingIndex = settings.hooks.Stop.findIndex(
|
|
113
|
+
(entry) => matcherEntryContainsCommand(entry, 'sync-permissions')
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (existingIndex !== -1) {
|
|
117
|
+
if (getCommandFromMatcherEntry(settings.hooks.Stop[existingIndex]) === command) {
|
|
118
|
+
logInfo('Permission sync hook already installed');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Stale path from a previous install — update in place
|
|
122
|
+
settings.hooks.Stop[existingIndex].hooks = [{ type: 'command', command }];
|
|
123
|
+
writeClaudeSettings(filePath, settings);
|
|
124
|
+
logSuccess('Permission sync hook updated in ~/.claude/settings.json');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
settings.hooks.Stop.push({
|
|
129
|
+
hooks: [{ type: 'command', command }],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
writeClaudeSettings(filePath, settings);
|
|
133
|
+
logSuccess('Permission sync hook installed in ~/.claude/settings.json');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Remove the permission-sync Stop hook from ~/.claude/settings.json.
|
|
138
|
+
*
|
|
139
|
+
* @param settingsPath - Override the settings file path (for testing).
|
|
140
|
+
*/
|
|
141
|
+
export function uninstallPermissionSyncHook(settingsPath?: string): void {
|
|
142
|
+
const filePath = settingsPath ?? getDefaultClaudeSettingsPath();
|
|
143
|
+
const settings = readClaudeSettings(filePath);
|
|
144
|
+
|
|
145
|
+
if (!settings.hooks?.Stop) {
|
|
146
|
+
logInfo('No permission sync hook found to remove');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const before = settings.hooks.Stop.length;
|
|
151
|
+
settings.hooks.Stop = settings.hooks.Stop.filter(
|
|
152
|
+
(entry) => !matcherEntryContainsCommand(entry, 'sync-permissions')
|
|
153
|
+
);
|
|
154
|
+
const after = settings.hooks.Stop.length;
|
|
155
|
+
|
|
156
|
+
if (before === after) {
|
|
157
|
+
logInfo('No permission sync hook found to remove');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Clean up empty arrays/objects
|
|
162
|
+
if (settings.hooks.Stop.length === 0) {
|
|
163
|
+
delete settings.hooks.Stop;
|
|
164
|
+
}
|
|
165
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
166
|
+
delete settings.hooks;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
writeClaudeSettings(filePath, settings);
|
|
170
|
+
logSuccess('Permission sync hook removed from ~/.claude/settings.json');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns true if a permission entry is reusable (should be promoted to global).
|
|
175
|
+
* Filters out absolute-path entries and heredoc commit messages.
|
|
176
|
+
*
|
|
177
|
+
* IMPORTANT: Keep in sync with the equivalent jq filter (IS_REUSABLE) in
|
|
178
|
+
* sync-permissions.sh — both must apply the same rules.
|
|
179
|
+
*/
|
|
180
|
+
export function isReusablePermission(entry: string): boolean {
|
|
181
|
+
// Skip entries with absolute user home paths
|
|
182
|
+
if (/\/Users\/|\/home\//.test(entry)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
// Skip heredoc commit messages
|
|
186
|
+
if (/cat <<.*EOF/.test(entry)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Synchronous sleep using Atomics.wait (no busy-wait CPU burn).
|
|
194
|
+
* Falls back to a short busy-wait if SharedArrayBuffer is unavailable.
|
|
195
|
+
*/
|
|
196
|
+
function sleepMs(ms: number): void {
|
|
197
|
+
try {
|
|
198
|
+
const buf = new SharedArrayBuffer(4);
|
|
199
|
+
const arr = new Int32Array(buf);
|
|
200
|
+
Atomics.wait(arr, 0, 0, ms);
|
|
201
|
+
} catch {
|
|
202
|
+
const end = Date.now() + ms;
|
|
203
|
+
while (Date.now() < end) { /* fallback busy wait */ }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Simple file-based lock using mkdir (atomic on all platforms).
|
|
209
|
+
* Returns a release function, or null if the lock could not be acquired.
|
|
210
|
+
*/
|
|
211
|
+
function acquireFileLock(lockPath: string, timeoutMs: number = 5000): (() => void) | null {
|
|
212
|
+
const start = Date.now();
|
|
213
|
+
while (true) {
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(lockPath);
|
|
216
|
+
return () => {
|
|
217
|
+
try { fs.rmdirSync(lockPath); } catch { /* ignore */ }
|
|
218
|
+
};
|
|
219
|
+
} catch {
|
|
220
|
+
if (Date.now() - start > timeoutMs) {
|
|
221
|
+
// Stale lock — force-remove and retry once
|
|
222
|
+
try { fs.rmdirSync(lockPath); } catch { /* ignore */ }
|
|
223
|
+
try {
|
|
224
|
+
fs.mkdirSync(lockPath);
|
|
225
|
+
return () => {
|
|
226
|
+
try { fs.rmdirSync(lockPath); } catch { /* ignore */ }
|
|
227
|
+
};
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
sleepMs(50);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface PermissionsBlock {
|
|
238
|
+
allow?: string[];
|
|
239
|
+
deny?: string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface SettingsWithPermissions {
|
|
243
|
+
permissions?: PermissionsBlock;
|
|
244
|
+
[key: string]: unknown;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Merge permissions from a project settings.local.json into the global settings.json.
|
|
249
|
+
* - Syncs both allow and deny entries
|
|
250
|
+
* - Skips non-reusable entries (absolute paths, heredoc commits)
|
|
251
|
+
* - Skips allow entries that conflict with the global deny list
|
|
252
|
+
* - Uses file locking to prevent concurrent write corruption
|
|
253
|
+
*
|
|
254
|
+
* Returns the total number of new entries added (allow + deny).
|
|
255
|
+
*/
|
|
256
|
+
export function mergePermissions(projectSettingsPath: string, globalSettingsPath: string): number {
|
|
257
|
+
if (!fs.existsSync(projectSettingsPath)) {
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let projectSettings: SettingsWithPermissions;
|
|
262
|
+
try {
|
|
263
|
+
projectSettings = JSON.parse(fs.readFileSync(projectSettingsPath, 'utf-8'));
|
|
264
|
+
} catch {
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const projectAllow = projectSettings?.permissions?.allow ?? [];
|
|
269
|
+
const projectDeny = projectSettings?.permissions?.deny ?? [];
|
|
270
|
+
if (projectAllow.length === 0 && projectDeny.length === 0) {
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Acquire lock for the read-modify-write cycle
|
|
275
|
+
const lockPath = globalSettingsPath + '.lock';
|
|
276
|
+
const releaseLock = acquireFileLock(lockPath);
|
|
277
|
+
if (!releaseLock) {
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
let globalSettings: SettingsWithPermissions;
|
|
283
|
+
if (fs.existsSync(globalSettingsPath)) {
|
|
284
|
+
try {
|
|
285
|
+
globalSettings = JSON.parse(fs.readFileSync(globalSettingsPath, 'utf-8'));
|
|
286
|
+
} catch {
|
|
287
|
+
globalSettings = {};
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
globalSettings = {};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!globalSettings.permissions) {
|
|
294
|
+
globalSettings.permissions = {};
|
|
295
|
+
}
|
|
296
|
+
if (!globalSettings.permissions.allow) {
|
|
297
|
+
globalSettings.permissions.allow = [];
|
|
298
|
+
}
|
|
299
|
+
if (!globalSettings.permissions.deny) {
|
|
300
|
+
globalSettings.permissions.deny = [];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const globalAllowSet = new Set(globalSettings.permissions.allow);
|
|
304
|
+
const globalDenySet = new Set(globalSettings.permissions.deny);
|
|
305
|
+
let added = 0;
|
|
306
|
+
|
|
307
|
+
// Merge allow entries (skip if in global deny)
|
|
308
|
+
for (const entry of projectAllow) {
|
|
309
|
+
if (!isReusablePermission(entry)) continue;
|
|
310
|
+
if (globalAllowSet.has(entry)) continue;
|
|
311
|
+
if (globalDenySet.has(entry)) continue;
|
|
312
|
+
globalSettings.permissions.allow.push(entry);
|
|
313
|
+
globalAllowSet.add(entry);
|
|
314
|
+
added++;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Merge deny entries
|
|
318
|
+
for (const entry of projectDeny) {
|
|
319
|
+
if (!isReusablePermission(entry)) continue;
|
|
320
|
+
if (globalDenySet.has(entry)) continue;
|
|
321
|
+
globalSettings.permissions.deny.push(entry);
|
|
322
|
+
globalDenySet.add(entry);
|
|
323
|
+
added++;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Clean up empty arrays
|
|
327
|
+
if (globalSettings.permissions.deny.length === 0) {
|
|
328
|
+
delete globalSettings.permissions.deny;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (added > 0) {
|
|
332
|
+
const dir = path.dirname(globalSettingsPath);
|
|
333
|
+
if (!fs.existsSync(dir)) {
|
|
334
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
335
|
+
}
|
|
336
|
+
const tmp = globalSettingsPath + '.tmp';
|
|
337
|
+
fs.writeFileSync(tmp, JSON.stringify(globalSettings, null, 2) + '\n', 'utf-8');
|
|
338
|
+
fs.renameSync(tmp, globalSettingsPath);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return added;
|
|
342
|
+
} finally {
|
|
343
|
+
releaseLock();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Scan all workspaces under a directory and merge their permissions into global.
|
|
349
|
+
*
|
|
350
|
+
* Directory structure: workspacesDir/workspace-name/repo-name/.claude/settings.local.json
|
|
351
|
+
* We check both the workspace level (for workspace-scoped settings) and one level deeper
|
|
352
|
+
* (for repo-scoped settings within each workspace).
|
|
353
|
+
*/
|
|
354
|
+
export function syncAllWorkspacePermissions(workspacesDir: string): void {
|
|
355
|
+
// Write to settings.json (not settings.local.json) because Claude Code only
|
|
356
|
+
// reads ~/.claude/settings.json at the user level. The .local.json variant
|
|
357
|
+
// is only recognized at the project level.
|
|
358
|
+
const globalSettingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
359
|
+
|
|
360
|
+
if (!fs.existsSync(workspacesDir)) {
|
|
361
|
+
logWarning(`Workspaces directory not found: ${workspacesDir}`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let totalAdded = 0;
|
|
366
|
+
const workspaces = fs.readdirSync(workspacesDir, { withFileTypes: true });
|
|
367
|
+
|
|
368
|
+
for (const workspace of workspaces) {
|
|
369
|
+
if (!workspace.isDirectory()) continue;
|
|
370
|
+
|
|
371
|
+
const workspacePath = path.join(workspacesDir, workspace.name);
|
|
372
|
+
|
|
373
|
+
// Check workspace-level settings
|
|
374
|
+
const workspaceSettings = path.join(workspacePath, '.claude', 'settings.local.json');
|
|
375
|
+
const wsAdded = mergePermissions(workspaceSettings, globalSettingsPath);
|
|
376
|
+
if (wsAdded > 0) {
|
|
377
|
+
logInfo(`Merged ${wsAdded} permission(s) from ${workspace.name}`);
|
|
378
|
+
totalAdded += wsAdded;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Check repo-level settings within the workspace
|
|
382
|
+
let repos: fs.Dirent[];
|
|
383
|
+
try {
|
|
384
|
+
repos = fs.readdirSync(workspacePath, { withFileTypes: true });
|
|
385
|
+
} catch {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const repo of repos) {
|
|
390
|
+
if (!repo.isDirectory()) continue;
|
|
391
|
+
|
|
392
|
+
const repoSettings = path.join(workspacePath, repo.name, '.claude', 'settings.local.json');
|
|
393
|
+
const repoAdded = mergePermissions(repoSettings, globalSettingsPath);
|
|
394
|
+
if (repoAdded > 0) {
|
|
395
|
+
logInfo(`Merged ${repoAdded} permission(s) from ${workspace.name}/${repo.name}`);
|
|
396
|
+
totalAdded += repoAdded;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (totalAdded > 0) {
|
|
402
|
+
logSuccess(`Synced ${totalAdded} new permission(s) to global settings`);
|
|
403
|
+
} else {
|
|
404
|
+
logInfo('No new permissions to sync');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Workspace-manager skills (MCP + CLI command skills)
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
function getSkillsSourceDir(): string {
|
|
413
|
+
// __dirname is dist/utils/ or src/utils/ — either way, two levels up is project root
|
|
414
|
+
const dir = path.resolve(__dirname, '..', '..', 'skills');
|
|
415
|
+
if (fs.existsSync(dir)) {
|
|
416
|
+
return dir;
|
|
417
|
+
}
|
|
418
|
+
throw new Error('Could not find skills/ directory. Make sure the package is installed correctly.');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Install all workspace-manager skills from the skills/ directory.
|
|
423
|
+
* Each .md file becomes a skill under <skillsDir>/<skill-name>/SKILL.md.
|
|
424
|
+
* Installs to all active agent skills directories.
|
|
425
|
+
* Idempotent — safe to call multiple times.
|
|
426
|
+
*/
|
|
427
|
+
export function installWorkspaceSkills(skillsDir?: string): void {
|
|
428
|
+
let sourceDir: string;
|
|
429
|
+
try {
|
|
430
|
+
sourceDir = getSkillsSourceDir();
|
|
431
|
+
} catch {
|
|
432
|
+
return; // skills dir not found — skip silently (non-critical)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// If a specific dir is provided (e.g. from tests), install only there.
|
|
436
|
+
// Otherwise install to all active agent directories.
|
|
437
|
+
const targetDirs = skillsDir ? [skillsDir] : getSkillsTargetDirs();
|
|
438
|
+
|
|
439
|
+
// Collect installable skills from source:
|
|
440
|
+
// 1. Flat .md files → installed as <name>/SKILL.md
|
|
441
|
+
// 2. Directories containing SKILL.md → copied recursively (includes references/)
|
|
442
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
443
|
+
const flatFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
444
|
+
const skillDirs = entries.filter(e => e.isDirectory() && fs.existsSync(path.join(sourceDir, e.name, 'SKILL.md')));
|
|
445
|
+
|
|
446
|
+
for (const targetBase of targetDirs) {
|
|
447
|
+
let installed = 0;
|
|
448
|
+
|
|
449
|
+
// Install flat .md files as <name>/SKILL.md
|
|
450
|
+
for (const entry of flatFiles) {
|
|
451
|
+
const skillName = entry.name.replace(/\.md$/, '');
|
|
452
|
+
const targetDir = path.join(targetBase, skillName);
|
|
453
|
+
const targetPath = path.join(targetDir, 'SKILL.md');
|
|
454
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
455
|
+
|
|
456
|
+
if (!fs.existsSync(targetDir)) {
|
|
457
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const content = fs.readFileSync(sourcePath, 'utf-8');
|
|
461
|
+
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
462
|
+
installed++;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Install skill directories (copy SKILL.md + references/ etc.)
|
|
466
|
+
for (const entry of skillDirs) {
|
|
467
|
+
const skillSourceDir = path.join(sourceDir, entry.name);
|
|
468
|
+
const targetDir = path.join(targetBase, entry.name);
|
|
469
|
+
copyDirRecursive(skillSourceDir, targetDir);
|
|
470
|
+
installed++;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (installed > 0) {
|
|
474
|
+
logSuccess(`Installed ${installed} workspace-manager skills in ${targetBase}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Recursively copy a directory, creating target dirs as needed. */
|
|
480
|
+
function copyDirRecursive(src: string, dest: string): void {
|
|
481
|
+
if (!fs.existsSync(dest)) {
|
|
482
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
483
|
+
}
|
|
484
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
485
|
+
const srcPath = path.join(src, entry.name);
|
|
486
|
+
const destPath = path.join(dest, entry.name);
|
|
487
|
+
if (entry.isDirectory()) {
|
|
488
|
+
copyDirRecursive(srcPath, destPath);
|
|
489
|
+
} else {
|
|
490
|
+
fs.copyFileSync(srcPath, destPath);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Remove all workspace-manager skills from all possible agent directories.
|
|
497
|
+
* Reads the skills/ source directory to know which skill names to remove.
|
|
498
|
+
* Removes from both Claude and Pi dirs regardless of current config to avoid orphans.
|
|
499
|
+
*/
|
|
500
|
+
export function uninstallWorkspaceSkills(skillsDir?: string): void {
|
|
501
|
+
let sourceDir: string;
|
|
502
|
+
try {
|
|
503
|
+
sourceDir = getSkillsSourceDir();
|
|
504
|
+
} catch {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
509
|
+
const flatFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
510
|
+
const skillDirs = entries.filter(e => e.isDirectory() && fs.existsSync(path.join(sourceDir, e.name, 'SKILL.md')));
|
|
511
|
+
|
|
512
|
+
// Collect all skill names to remove
|
|
513
|
+
const skillNames = [
|
|
514
|
+
...flatFiles.map(e => e.name.replace(/\.md$/, '')),
|
|
515
|
+
...skillDirs.map(e => e.name),
|
|
516
|
+
];
|
|
517
|
+
|
|
518
|
+
// If specific dir provided, use that. Otherwise, uninstall from ALL agent dirs
|
|
519
|
+
// to avoid orphan skills when user changes config.
|
|
520
|
+
// Note: OpenCode reads from ~/.claude/skills/ natively, so no separate dir needed.
|
|
521
|
+
const targetDirs = skillsDir ? [skillsDir] : [
|
|
522
|
+
getAgentPaths('claude').skillsDir,
|
|
523
|
+
getAgentPaths('pi').skillsDir,
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
for (const targetBase of targetDirs) {
|
|
527
|
+
for (const skillName of skillNames) {
|
|
528
|
+
const targetDir = path.join(targetBase, skillName);
|
|
529
|
+
if (fs.existsSync(targetDir)) {
|
|
530
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ─── Workspace status-line ────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
/** Absolute install path for the workspace-table Python script. */
|
|
539
|
+
const WORKSPACE_TABLE_SCRIPT = path.join(os.homedir(), '.workspace-manager-table.py');
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Resolve the bundled workspace-table.py path from the installed package.
|
|
543
|
+
* When compiled, __dirname is dist/utils/; the scripts/ dir is at dist/scripts/.
|
|
544
|
+
*/
|
|
545
|
+
function getWorkspaceTableSourcePath(): string {
|
|
546
|
+
const distPath = path.join(__dirname, '..', 'scripts', 'workspace-table.py');
|
|
547
|
+
const srcPath = path.join(__dirname, '..', '..', 'src', 'scripts', 'workspace-table.py');
|
|
548
|
+
if (fs.existsSync(distPath)) return distPath;
|
|
549
|
+
if (fs.existsSync(srcPath)) return srcPath;
|
|
550
|
+
throw new Error('Could not find workspace-table.py in package');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Install the workspace repo-table as an *additional* statusLine in Claude Code.
|
|
555
|
+
*
|
|
556
|
+
* The script only outputs content when the cwd is inside a workspace AND at
|
|
557
|
+
* least one repo has a non-default branch. Otherwise it exits silently and
|
|
558
|
+
* Claude Code shows its default footer — the user's existing styling is
|
|
559
|
+
* never affected.
|
|
560
|
+
*
|
|
561
|
+
* The script is copied to ~/.workspace-manager-table.py and registered in
|
|
562
|
+
* ~/.claude/settings.json only when no statusLine is already configured —
|
|
563
|
+
* we never overwrite a custom status line the user already has.
|
|
564
|
+
*
|
|
565
|
+
* @param settingsPath Override ~/.claude/settings.json (for testing).
|
|
566
|
+
*/
|
|
567
|
+
export function installWorkspaceStatusLine(settingsPath?: string): void {
|
|
568
|
+
const filePath = settingsPath ?? getDefaultClaudeSettingsPath();
|
|
569
|
+
const settings = readClaudeSettings(filePath);
|
|
570
|
+
|
|
571
|
+
if ((settings as any).statusLine) {
|
|
572
|
+
logInfo('statusLine already configured in ~/.claude/settings.json — skipping workspace-table install');
|
|
573
|
+
logInfo(` To use workspace-manager\'s table, replace your statusLine command with: ${WORKSPACE_TABLE_SCRIPT}`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Copy the script
|
|
578
|
+
let sourcePath: string;
|
|
579
|
+
try {
|
|
580
|
+
sourcePath = getWorkspaceTableSourcePath();
|
|
581
|
+
} catch {
|
|
582
|
+
logWarning('Could not locate workspace-table.py — skipping status-line install');
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
fs.copyFileSync(sourcePath, WORKSPACE_TABLE_SCRIPT);
|
|
586
|
+
fs.chmodSync(WORKSPACE_TABLE_SCRIPT, 0o755);
|
|
587
|
+
|
|
588
|
+
// Register in settings.json
|
|
589
|
+
(settings as any).statusLine = {
|
|
590
|
+
type: 'command',
|
|
591
|
+
command: WORKSPACE_TABLE_SCRIPT,
|
|
592
|
+
padding: 0,
|
|
593
|
+
};
|
|
594
|
+
writeClaudeSettings(filePath, settings);
|
|
595
|
+
logSuccess(`Workspace status-line installed (${WORKSPACE_TABLE_SCRIPT})`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Remove the workspace status-line from Claude Code\'s settings.json.
|
|
600
|
+
* Only removes it if the command points to workspace-manager\'s own script.
|
|
601
|
+
*
|
|
602
|
+
* @param settingsPath Override ~/.claude/settings.json (for testing).
|
|
603
|
+
*/
|
|
604
|
+
export function uninstallWorkspaceStatusLine(settingsPath?: string): void {
|
|
605
|
+
const filePath = settingsPath ?? getDefaultClaudeSettingsPath();
|
|
606
|
+
const settings = readClaudeSettings(filePath);
|
|
607
|
+
const existing = (settings as any).statusLine;
|
|
608
|
+
|
|
609
|
+
if (!existing) {
|
|
610
|
+
logInfo('No statusLine configured — nothing to remove');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const cmd: string = existing.command ?? '';
|
|
615
|
+
if (!cmd.includes('workspace-manager-table')) {
|
|
616
|
+
logInfo('statusLine is not managed by workspace-manager — leaving it unchanged');
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
delete (settings as any).statusLine;
|
|
621
|
+
writeClaudeSettings(filePath, settings);
|
|
622
|
+
|
|
623
|
+
// Remove the installed script if it exists
|
|
624
|
+
if (fs.existsSync(WORKSPACE_TABLE_SCRIPT)) {
|
|
625
|
+
try { fs.unlinkSync(WORKSPACE_TABLE_SCRIPT); } catch { /* ignore */ }
|
|
626
|
+
}
|
|
627
|
+
logSuccess('Workspace status-line removed from ~/.claude/settings.json');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ─── Self-heal stale hook paths ──────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
/** Extract the script path (…/foo.sh or …/foo.py) referenced by a hook command. */
|
|
633
|
+
function extractScriptPath(command: string): string | null {
|
|
634
|
+
// Commands look like: bash "/abs/path/sync-permissions.sh" or /abs/path/table.py
|
|
635
|
+
const quoted = command.match(/"([^"]+\.(?:sh|py|js))"/);
|
|
636
|
+
if (quoted) return quoted[1];
|
|
637
|
+
const bare = command.match(/(\/[^\s"']+\.(?:sh|py|js))/);
|
|
638
|
+
return bare ? bare[1] : null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Detect and repair workspace-manager hook commands in ~/.claude/settings.json
|
|
643
|
+
* that point to script paths which no longer exist on disk.
|
|
644
|
+
*
|
|
645
|
+
* This happens when the package is installed via a manager (e.g. Volta) whose
|
|
646
|
+
* postinstall runs from an ephemeral temp staging dir
|
|
647
|
+
* (~/.volta/tmp/image/packages/.tmpXXXX/...). That dead path gets baked into
|
|
648
|
+
* settings.json; once the temp dir is cleaned up the Stop/SessionStart/
|
|
649
|
+
* PostToolUse hooks fail with "No such file or directory".
|
|
650
|
+
*
|
|
651
|
+
* Called best-effort on CLI startup. Because the CLI runs from the package's
|
|
652
|
+
* STABLE install location (the bin shim resolves there), re-resolving the hook
|
|
653
|
+
* commands here produces correct, durable paths.
|
|
654
|
+
*
|
|
655
|
+
* Returns the number of hooks repaired. Silent and non-throwing.
|
|
656
|
+
*
|
|
657
|
+
* @param settingsPath Override ~/.claude/settings.json (for testing).
|
|
658
|
+
*/
|
|
659
|
+
export function repairStaleHooks(settingsPath?: string): number {
|
|
660
|
+
const filePath = settingsPath ?? getDefaultClaudeSettingsPath();
|
|
661
|
+
|
|
662
|
+
let settings: ClaudeSettings;
|
|
663
|
+
try {
|
|
664
|
+
if (!fs.existsSync(filePath)) return 0;
|
|
665
|
+
settings = readClaudeSettings(filePath);
|
|
666
|
+
} catch {
|
|
667
|
+
return 0;
|
|
668
|
+
}
|
|
669
|
+
if (!settings.hooks) return 0;
|
|
670
|
+
|
|
671
|
+
// (hook category, command-substring identifying our hook, fresh-command resolver)
|
|
672
|
+
const specs: Array<{
|
|
673
|
+
category: 'Stop' | 'SessionStart' | 'PostToolUse';
|
|
674
|
+
needle: string;
|
|
675
|
+
resolve: () => string;
|
|
676
|
+
}> = [
|
|
677
|
+
{ category: 'Stop', needle: 'sync-permissions', resolve: getPermissionSyncHookCommand },
|
|
678
|
+
];
|
|
679
|
+
|
|
680
|
+
let repaired = 0;
|
|
681
|
+
|
|
682
|
+
for (const spec of specs) {
|
|
683
|
+
const entries = settings.hooks[spec.category];
|
|
684
|
+
if (!Array.isArray(entries)) continue;
|
|
685
|
+
|
|
686
|
+
for (const entry of entries) {
|
|
687
|
+
if (!matcherEntryContainsCommand(entry, spec.needle)) continue;
|
|
688
|
+
const current = getCommandFromMatcherEntry(entry);
|
|
689
|
+
if (!current) continue;
|
|
690
|
+
|
|
691
|
+
const scriptPath = extractScriptPath(current);
|
|
692
|
+
// Only repair when the referenced script is genuinely missing.
|
|
693
|
+
if (scriptPath && fs.existsSync(scriptPath)) continue;
|
|
694
|
+
|
|
695
|
+
let fresh: string;
|
|
696
|
+
try {
|
|
697
|
+
fresh = spec.resolve();
|
|
698
|
+
} catch {
|
|
699
|
+
continue; // can't resolve a stable path right now — leave as-is
|
|
700
|
+
}
|
|
701
|
+
if (fresh === current) continue;
|
|
702
|
+
|
|
703
|
+
if (entry.hooks) {
|
|
704
|
+
entry.hooks = entry.hooks.map((h) =>
|
|
705
|
+
h.command.includes(spec.needle) ? { ...h, command: fresh } : h
|
|
706
|
+
);
|
|
707
|
+
repaired++;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (repaired > 0) {
|
|
713
|
+
try {
|
|
714
|
+
writeClaudeSettings(filePath, settings);
|
|
715
|
+
} catch {
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return repaired;
|
|
720
|
+
}
|