fitout 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -17,7 +17,7 @@ Fitout ensures your actual runtime state matches your declared configuration.
17
17
 
18
18
  1. Declare desired plugins in `.claude/fitout.toml`
19
19
  2. Run `fitout status` to see the diff
20
- 3. Run `fitout apply` to sync
20
+ 3. Run `fitout install` to sync
21
21
 
22
22
  ## Installation
23
23
 
@@ -71,7 +71,7 @@ Context: /path/to/project
71
71
  Install missing plugins:
72
72
 
73
73
  ```bash
74
- fitout apply
74
+ fitout install
75
75
  ```
76
76
 
77
77
  ## Commands
@@ -86,13 +86,13 @@ Shows the diff between desired and installed plugins.
86
86
 
87
87
  Exit code is `1` if any plugins are missing, `0` otherwise.
88
88
 
89
- ### `fitout apply`
89
+ ### `fitout install`
90
90
 
91
91
  Installs missing plugins to sync with config.
92
92
 
93
93
  ```bash
94
- fitout apply # Install missing plugins
95
- fitout apply --dry-run # Preview what would be installed
94
+ fitout install # Install missing plugins
95
+ fitout install --dry-run # Preview what would be installed
96
96
  ```
97
97
 
98
98
  ## Profiles
package/dist/claude.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface InstalledPlugin {
5
5
  enabled: boolean;
6
6
  projectPath?: string;
7
7
  }
8
+ export declare function claudeEnv(): NodeJS.ProcessEnv;
8
9
  export declare function parsePluginList(jsonOutput: string): InstalledPlugin[];
9
10
  export declare function listPlugins(): InstalledPlugin[];
10
11
  export declare function installPlugin(pluginId: string): void;
package/dist/claude.js CHANGED
@@ -1,4 +1,16 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // Claude CLI sets CLAUDECODE=1 to detect nested sessions and refuses to run
6
+ // if it's present. Fitout calls `claude plugin list/install` from SessionStart
7
+ // hooks, which inherit this env var. These are safe metadata/management commands,
8
+ // not nested interactive sessions, so we strip the variable.
9
+ export function claudeEnv() {
10
+ const env = { ...process.env };
11
+ delete env.CLAUDECODE;
12
+ return env;
13
+ }
2
14
  export function parsePluginList(jsonOutput) {
3
15
  const parsed = JSON.parse(jsonOutput);
4
16
  if (!Array.isArray(parsed)) {
@@ -7,14 +19,27 @@ export function parsePluginList(jsonOutput) {
7
19
  return parsed;
8
20
  }
9
21
  export function listPlugins() {
10
- const output = execFileSync('claude', ['plugin', 'list', '--json'], {
11
- encoding: 'utf-8',
12
- });
13
- return parsePluginList(output);
22
+ // Use file redirection to work around Claude CLI stdout truncation at 64KB
23
+ // when piped to a non-tty. File redirection captures the full output.
24
+ const tmpDir = mkdtempSync(join(tmpdir(), 'fitout-'));
25
+ const tmpFile = join(tmpDir, 'plugins.json');
26
+ try {
27
+ execSync(`claude plugin list --json > "${tmpFile}"`, {
28
+ encoding: 'utf-8',
29
+ env: claudeEnv(),
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ });
32
+ const output = readFileSync(tmpFile, 'utf-8');
33
+ return parsePluginList(output);
34
+ }
35
+ finally {
36
+ rmSync(tmpDir, { recursive: true, force: true });
37
+ }
14
38
  }
15
39
  export function installPlugin(pluginId) {
16
40
  execFileSync('claude', ['plugin', 'install', pluginId, '--scope', 'local'], {
17
41
  encoding: 'utf-8',
42
+ env: claudeEnv(),
18
43
  stdio: 'inherit',
19
44
  });
20
45
  }
package/dist/cli.js CHANGED
File without changes
@@ -1,10 +1,10 @@
1
1
  export { getGlobalConfigDir, getGlobalConfigPath } from './paths.js';
2
2
  export interface GlobalConfig {
3
- marketplaces?: Record<string, string>;
3
+ marketplaces?: string[];
4
4
  }
5
5
  export declare function readGlobalConfig(): GlobalConfig;
6
6
  export declare function writeGlobalConfig(config: GlobalConfig): void;
7
7
  export declare function hasGlobalConfig(): boolean;
8
- export declare function getConfiguredMarketplaces(): Record<string, string>;
9
- export declare function getGlobalConfigContent(marketplaces?: Record<string, string>): string;
10
- export declare function createGlobalConfig(marketplaces?: Record<string, string>): boolean;
8
+ export declare function getConfiguredMarketplaces(): string[];
9
+ export declare function getGlobalConfigContent(marketplaces?: string[]): string;
10
+ export declare function createGlobalConfig(marketplaces?: string[]): boolean;
@@ -27,27 +27,24 @@ export function hasGlobalConfig() {
27
27
  }
28
28
  export function getConfiguredMarketplaces() {
29
29
  const config = readGlobalConfig();
30
- return config.marketplaces || {};
30
+ return config.marketplaces || [];
31
31
  }
32
32
  export function getGlobalConfigContent(marketplaces) {
33
- if (!marketplaces || Object.keys(marketplaces).length === 0) {
34
- return `# Fettle global config
35
- # Marketplaces and their sources
33
+ if (!marketplaces || marketplaces.length === 0) {
34
+ return `# Fitout global config
35
+ # Marketplace sources to ensure are installed
36
36
 
37
- [marketplaces]
38
- # pickled-claude-plugins = "https://github.com/technicalpickles/pickled-claude-plugins"
37
+ marketplaces = []
39
38
  `;
40
39
  }
41
- const lines = [
42
- '# Fettle global config',
43
- '# Marketplaces and their sources',
44
- '',
45
- '[marketplaces]',
46
- ];
47
- for (const [name, source] of Object.entries(marketplaces)) {
48
- lines.push(`${name} = "${source}"`);
49
- }
50
- return lines.join('\n') + '\n';
40
+ const quotedSources = marketplaces.map((s) => ` "${s}"`).join(',\n');
41
+ return `# Fitout global config
42
+ # Marketplace sources to ensure are installed
43
+
44
+ marketplaces = [
45
+ ${quotedSources},
46
+ ]
47
+ `;
51
48
  }
52
49
  export function createGlobalConfig(marketplaces) {
53
50
  const configPath = getGlobalConfigPath();
package/dist/init.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export declare function readClaudeSettings(path: string): Record<string, unknown>;
2
2
  export type HookStatus = 'none' | 'current' | 'outdated';
3
+ export declare const HOOK_COMMAND_DEFAULT = "npx fitout@latest install --hook";
4
+ export declare const HOOK_COMMAND_DEV = "fitout install --hook";
3
5
  export declare function getFitoutHookStatus(settings: Record<string, unknown>): HookStatus;
4
6
  export declare function hasFitoutHook(settings: Record<string, unknown>): boolean;
5
7
  interface ClaudeSettings {
package/dist/init.js CHANGED
@@ -14,17 +14,31 @@ export function readClaudeSettings(path) {
14
14
  return {};
15
15
  }
16
16
  }
17
+ // Hook command patterns
18
+ export const HOOK_COMMAND_DEFAULT = 'npx fitout@latest install --hook';
19
+ export const HOOK_COMMAND_DEV = 'fitout install --hook';
20
+ function isCurrentHook(command) {
21
+ if (!command)
22
+ return false;
23
+ // Match both "npx fitout@latest install --hook" and "fitout install --hook"
24
+ return command.includes('fitout install --hook') || command.includes('fitout@latest install --hook');
25
+ }
26
+ function isLegacyHook(command) {
27
+ if (!command)
28
+ return false;
29
+ return command.includes('fitout apply --hook') || command.includes('fitout@latest apply --hook');
30
+ }
17
31
  export function getFitoutHookStatus(settings) {
18
32
  const hooks = settings.hooks;
19
33
  if (!hooks?.SessionStart)
20
34
  return 'none';
21
35
  const sessionStartHooks = hooks.SessionStart;
22
- // Check for current command first
23
- const hasCurrent = sessionStartHooks.some((matcher) => matcher.hooks?.some((hook) => hook.command?.includes('fitout install --hook')));
36
+ // Check for current command (matches both npx and dev versions)
37
+ const hasCurrent = sessionStartHooks.some((matcher) => matcher.hooks?.some((hook) => isCurrentHook(hook.command)));
24
38
  if (hasCurrent)
25
39
  return 'current';
26
40
  // Check for legacy command
27
- const hasLegacy = sessionStartHooks.some((matcher) => matcher.hooks?.some((hook) => hook.command?.includes('fitout apply --hook')));
41
+ const hasLegacy = sessionStartHooks.some((matcher) => matcher.hooks?.some((hook) => isLegacyHook(hook.command)));
28
42
  if (hasLegacy)
29
43
  return 'outdated';
30
44
  return 'none';
@@ -43,7 +57,7 @@ export function addFitoutHook(settings) {
43
57
  }
44
58
  result.hooks.SessionStart.push({
45
59
  hooks: [
46
- { type: 'command', command: 'fitout install --hook' }
60
+ { type: 'command', command: HOOK_COMMAND_DEFAULT }
47
61
  ]
48
62
  });
49
63
  return result;
@@ -56,8 +70,11 @@ export function upgradeFitoutHook(settings) {
56
70
  for (const matcher of result.hooks.SessionStart) {
57
71
  if (matcher.hooks) {
58
72
  for (const hook of matcher.hooks) {
59
- if (hook.command?.includes('fitout apply --hook')) {
60
- hook.command = hook.command.replace('fitout apply --hook', 'fitout install --hook');
73
+ if (isLegacyHook(hook.command)) {
74
+ // Replace both legacy patterns with the new default
75
+ hook.command = hook.command
76
+ .replace('fitout@latest apply --hook', HOOK_COMMAND_DEFAULT)
77
+ .replace('fitout apply --hook', HOOK_COMMAND_DEFAULT);
61
78
  }
62
79
  }
63
80
  }
@@ -107,7 +124,7 @@ Read \`~/.claude/settings.json\` and verify the Fitout hook exists:
107
124
  "SessionStart": [
108
125
  {
109
126
  "hooks": [
110
- { "type": "command", "command": "fitout install --hook" }
127
+ { "type": "command", "command": "npx fitout@latest install --hook" }
111
128
  ]
112
129
  }
113
130
  ]
@@ -115,6 +132,8 @@ Read \`~/.claude/settings.json\` and verify the Fitout hook exists:
115
132
  }
116
133
  \`\`\`
117
134
 
135
+ Note: Developers may use \`fitout install --hook\` (without npx) for local development.
136
+
118
137
  If the hook is missing, suggest running \`fitout init\`.
119
138
 
120
139
  ### 2. Check Plugin Status
package/dist/install.js CHANGED
@@ -133,13 +133,13 @@ export function runInstall(cwd, options = {}) {
133
133
  }
134
134
  // Ensure configured marketplaces are installed (skip in hook mode for cleaner output)
135
135
  if (!options.hook && hasGlobalConfig()) {
136
- const marketplaces = getConfiguredMarketplaces();
137
- if (Object.keys(marketplaces).length > 0) {
138
- const marketplaceResult = ensureMarketplaces();
136
+ const marketplaceSources = getConfiguredMarketplaces();
137
+ if (marketplaceSources.length > 0) {
138
+ const marketplaceResult = ensureMarketplaces(marketplaceSources);
139
139
  if (marketplaceResult.added.length > 0) {
140
140
  console.log(colors.header('Marketplaces:'));
141
- for (const name of marketplaceResult.added) {
142
- console.log(` ${symbols.install} ${name}`);
141
+ for (const source of marketplaceResult.added) {
142
+ console.log(` ${symbols.install} ${source}`);
143
143
  }
144
144
  console.log('');
145
145
  }
@@ -4,16 +4,15 @@ export interface AvailablePlugin {
4
4
  version: string;
5
5
  marketplace: string;
6
6
  }
7
+ export interface InstalledMarketplace {
8
+ name: string;
9
+ source: 'github' | 'git' | string;
10
+ repo?: string;
11
+ url?: string;
12
+ installLocation: string;
13
+ }
7
14
  export declare function listAvailablePlugins(): AvailablePlugin[];
8
15
  export declare function refreshMarketplaces(): void;
9
- /**
10
- * Get list of marketplace names that are installed locally
11
- */
12
- export declare function getInstalledMarketplaces(): string[];
13
- /**
14
- * Check if a marketplace is installed
15
- */
16
- export declare function isMarketplaceInstalled(name: string): boolean;
17
16
  /**
18
17
  * Add a marketplace from a source URL
19
18
  */
@@ -22,11 +21,19 @@ export interface EnsureMarketplacesResult {
22
21
  added: string[];
23
22
  alreadyInstalled: string[];
24
23
  failed: {
25
- name: string;
24
+ source: string;
26
25
  error: string;
27
26
  }[];
28
27
  }
29
28
  /**
30
- * Ensure all configured marketplaces are installed
29
+ * Ensure all configured marketplace sources are installed
30
+ */
31
+ export declare function ensureMarketplaces(sources: string[]): EnsureMarketplacesResult;
32
+ /**
33
+ * List installed marketplaces using Claude CLI JSON output
34
+ */
35
+ export declare function listInstalledMarketplaces(): InstalledMarketplace[];
36
+ /**
37
+ * Check if a marketplace source URL is already installed
31
38
  */
32
- export declare function ensureMarketplaces(): EnsureMarketplacesResult;
39
+ export declare function isMarketplaceSourceInstalled(source: string): boolean;
@@ -1,7 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { getConfiguredMarketplaces } from './globalConfig.js';
5
4
  import { getMarketplacesDir } from './paths.js';
6
5
  export { getMarketplacesDir } from './paths.js';
7
6
  export function listAvailablePlugins() {
@@ -41,25 +40,6 @@ export function refreshMarketplaces() {
41
40
  stdio: 'inherit',
42
41
  });
43
42
  }
44
- /**
45
- * Get list of marketplace names that are installed locally
46
- */
47
- export function getInstalledMarketplaces() {
48
- const marketplacesDir = getMarketplacesDir();
49
- if (!existsSync(marketplacesDir)) {
50
- return [];
51
- }
52
- return readdirSync(marketplacesDir, { withFileTypes: true })
53
- .filter((d) => d.isDirectory())
54
- .map((d) => d.name);
55
- }
56
- /**
57
- * Check if a marketplace is installed
58
- */
59
- export function isMarketplaceInstalled(name) {
60
- const marketplacesDir = getMarketplacesDir();
61
- return existsSync(join(marketplacesDir, name));
62
- }
63
43
  /**
64
44
  * Add a marketplace from a source URL
65
45
  */
@@ -70,27 +50,26 @@ export function addMarketplace(source) {
70
50
  });
71
51
  }
72
52
  /**
73
- * Ensure all configured marketplaces are installed
53
+ * Ensure all configured marketplace sources are installed
74
54
  */
75
- export function ensureMarketplaces() {
76
- const configured = getConfiguredMarketplaces();
55
+ export function ensureMarketplaces(sources) {
77
56
  const result = {
78
57
  added: [],
79
58
  alreadyInstalled: [],
80
59
  failed: [],
81
60
  };
82
- for (const [name, source] of Object.entries(configured)) {
83
- if (isMarketplaceInstalled(name)) {
84
- result.alreadyInstalled.push(name);
61
+ for (const source of sources) {
62
+ if (isMarketplaceSourceInstalled(source)) {
63
+ result.alreadyInstalled.push(source);
85
64
  }
86
65
  else {
87
66
  try {
88
67
  addMarketplace(source);
89
- result.added.push(name);
68
+ result.added.push(source);
90
69
  }
91
70
  catch (err) {
92
71
  result.failed.push({
93
- name,
72
+ source,
94
73
  error: err instanceof Error ? err.message : 'Unknown error',
95
74
  });
96
75
  }
@@ -98,3 +77,43 @@ export function ensureMarketplaces() {
98
77
  }
99
78
  return result;
100
79
  }
80
+ /**
81
+ * List installed marketplaces using Claude CLI JSON output
82
+ */
83
+ export function listInstalledMarketplaces() {
84
+ try {
85
+ const output = execFileSync('claude', ['plugin', 'marketplace', 'list', '--json'], {
86
+ encoding: 'utf-8',
87
+ });
88
+ return JSON.parse(output);
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
94
+ /**
95
+ * Normalize a marketplace source URL to extract owner/repo for comparison
96
+ */
97
+ function normalizeGitHubSource(source) {
98
+ // Match github.com URLs with or without .git suffix
99
+ const match = source.match(/github\.com\/([^/]+\/[^/.]+)/);
100
+ return match ? match[1] : null;
101
+ }
102
+ /**
103
+ * Check if a marketplace source URL is already installed
104
+ */
105
+ export function isMarketplaceSourceInstalled(source) {
106
+ const installed = listInstalledMarketplaces();
107
+ const normalizedSource = normalizeGitHubSource(source);
108
+ return installed.some((m) => {
109
+ // Check github source by repo
110
+ if (m.source === 'github' && m.repo && normalizedSource) {
111
+ return m.repo === normalizedSource;
112
+ }
113
+ // Check git source by URL
114
+ if (m.source === 'git' && m.url) {
115
+ return m.url === source;
116
+ }
117
+ return false;
118
+ });
119
+ }
package/dist/update.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { claudeEnv } from './claude.js';
2
3
  /**
3
4
  * Compare semver versions. Returns:
4
5
  * -1 if a < b
@@ -49,6 +50,7 @@ export function findOutdatedPlugins(installed, available, projectPath) {
49
50
  export function updatePlugin(pluginId, scope = 'local') {
50
51
  execFileSync('claude', ['plugin', 'update', pluginId, '--scope', scope], {
51
52
  encoding: 'utf-8',
53
+ env: claudeEnv(),
52
54
  stdio: 'inherit',
53
55
  });
54
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fitout",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Context-aware plugin manager for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^25.1.0",
42
+ "semantic-release": "^25.0.3",
42
43
  "tsx": "^4.21.0",
43
44
  "typescript": "^5.9.3",
44
45
  "vitest": "^4.0.18"