peaks-cli 1.0.5 → 1.0.7

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/LICENSE CHANGED
@@ -1,52 +1,21 @@
1
- Peaks Closed-Source Non-Commercial License
2
- Peaks 闭源非商用许可协议
3
-
4
- English Version
5
-
6
- Copyright (c) 2026 Peaks contributors. All rights reserved.
7
-
8
- 1. License Grant
9
- You may use this software only for personal, internal evaluation, research, learning, or other non-commercial purposes authorized by the copyright holder.
10
-
11
- 2. Commercial Use Prohibited
12
- Commercial use is prohibited without prior written permission from the copyright holder. Commercial use includes, but is not limited to, using the software to provide paid services, support commercial operations, generate revenue, integrate into commercial products, or use within a for-profit organization for business purposes.
13
-
14
- 3. Modification Prohibited for Commercial Purposes
15
- You may not modify, adapt, translate, create derivative works from, or otherwise alter this software for any commercial purpose without prior written permission from the copyright holder.
16
-
17
- 4. Redistribution Prohibited for Commercial Purposes
18
- You may not distribute, sublicense, sell, rent, lease, publish, host, mirror, package, bundle, or otherwise make this software or modified versions available to others for any commercial purpose without prior written permission from the copyright holder.
19
-
20
- 5. No Open-Source License
21
- This software is closed source. No rights are granted except those expressly stated in this license. All rights not expressly granted are reserved by the copyright holder.
22
-
23
- 6. No Warranty
24
- This software is provided "as is", without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement. The copyright holder is not liable for any claim, damages, or other liability arising from the software or its use.
25
-
26
- 7. Additional Permission
27
- For commercial licensing, modification, distribution, or other permissions not granted by this license, contact the copyright holder and obtain written permission before proceeding.
28
-
29
- 中文版本
30
-
31
- 版权所有 (c) 2026 Peaks 贡献者。保留所有权利。
32
-
33
- 1. 授权范围
34
- 你仅可将本软件用于个人使用、内部评估、研究、学习,或版权持有人授权的其他非商业用途。
35
-
36
- 2. 禁止商业使用
37
- 未经版权持有人事先书面许可,禁止将本软件用于任何商业用途。商业用途包括但不限于:使用本软件提供付费服务、支持商业运营、产生收入、集成到商业产品中,或在营利性组织内用于业务目的。
38
-
39
- 3. 禁止商业目的的修改
40
- 未经版权持有人事先书面许可,你不得为了任何商业目的修改、改编、翻译、创作衍生作品,或以其他方式变更本软件。
41
-
42
- 4. 禁止商业目的的分发
43
- 未经版权持有人事先书面许可,你不得为了任何商业目的分发、再授权、销售、出租、租赁、发布、托管、镜像、打包、捆绑,或以其他方式向他人提供本软件或其修改版本。
44
-
45
- 5. 非开源许可
46
- 本软件为闭源软件。除本许可明确授予的权利外,不授予任何其他权利。所有未明确授予的权利均由版权持有人保留。
47
-
48
- 6. 无担保
49
- 本软件按“现状”提供,不附带任何明示或默示担保,包括但不限于适销性、特定用途适用性和不侵权担保。版权持有人不对因本软件或其使用产生的任何索赔、损害或其他责任承担责任。
50
-
51
- 7. 额外许可
52
- 如需商业授权、修改、分发或本许可未授予的其他权限,请在行动前联系版权持有人并取得书面许可。
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peaks contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -410,7 +410,7 @@ pnpm run build
410
410
 
411
411
  ## 许可
412
412
 
413
- 本仓库使用闭源非商用许可,详见 [LICENSE](LICENSE)。未经版权持有人事先书面许可,禁止商业使用、禁止商业目的的修改,禁止商业目的的分发、再授权、销售、托管、打包或捆绑。
413
+ 本仓库使用 MIT 许可完全开源,详见 [LICENSE](LICENSE)
414
414
 
415
415
  ## 设计立场
416
416
 
package/bin/peaks.js CHANGED
File without changes
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerShadcnCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,35 @@
1
+ import { createShadcnInvocation, executeShadcnInvocation } from '../../services/shadcn/shadcn-service.js';
2
+ import { fail } from '../../shared/result.js';
3
+ import { getErrorMessage, printResult, redactSensitiveErrorMessage } from '../cli-helpers.js';
4
+ function printShadcnFailure(io, error, exitCode = 1) {
5
+ printResult(io, fail('shadcn', 'SHADCN_COMMAND_FAILED', redactSensitiveErrorMessage(getErrorMessage(error)), {}, ['Check the shadcn command arguments before retrying']), false);
6
+ process.exitCode = exitCode;
7
+ }
8
+ async function runShadcnCommand(io, args) {
9
+ try {
10
+ const invocation = createShadcnInvocation({ args });
11
+ const result = await executeShadcnInvocation(invocation);
12
+ const didFail = result.exitCode !== null && result.exitCode !== 0;
13
+ if (result.stdout.length > 0) {
14
+ io.stdout((didFail ? redactSensitiveErrorMessage(result.stdout) : result.stdout).trimEnd());
15
+ }
16
+ if (result.stderr.length > 0) {
17
+ io.stderr((didFail ? redactSensitiveErrorMessage(result.stderr) : result.stderr).trimEnd());
18
+ }
19
+ if (didFail) {
20
+ process.exitCode = result.exitCode;
21
+ }
22
+ }
23
+ catch (error) {
24
+ printShadcnFailure(io, error);
25
+ }
26
+ }
27
+ export function registerShadcnCommands(program, io) {
28
+ program
29
+ .command('shadcn')
30
+ .description('Run the pinned shadcn CLI bundled with Peaks')
31
+ .allowUnknownOption(true)
32
+ .helpOption(false)
33
+ .argument('<args...>', 'arguments forwarded to shadcn')
34
+ .action((args) => runShadcnCommand(io, args));
35
+ }
@@ -4,6 +4,7 @@ import { registerCoreAndArtifactCommands } from './commands/core-artifact-comman
4
4
  import { registerWorkflowCommands } from './commands/workflow-commands.js';
5
5
  import { registerCapabilityWorkerConfigAndSCCommands } from './commands/capability-worker-config-sc-commands.js';
6
6
  import { registerCodegraphCommands } from './commands/codegraph-commands.js';
7
+ import { registerShadcnCommands } from './commands/shadcn-commands.js';
7
8
  export { printResult } from './cli-helpers.js';
8
9
  export function createProgram(io = { stdout: (text) => console.log(text), stderr: (text) => console.error(text) }) {
9
10
  const program = new Command();
@@ -26,5 +27,6 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
26
27
  registerWorkflowCommands(program, io);
27
28
  registerCapabilityWorkerConfigAndSCCommands(program, io);
28
29
  registerCodegraphCommands(program, io);
30
+ registerShadcnCommands(program, io);
29
31
  return program;
30
32
  }
@@ -1,4 +1,5 @@
1
- declare const CODEGRAPH_PACKAGE_NAME = "@colbymchenry/codegraph@0.7.10";
1
+ declare const CODEGRAPH_PACKAGE_NAME = "@colbymchenry/codegraph";
2
+ declare const CODEGRAPH_PACKAGE_VERSION = "0.7.10";
2
3
  declare const CODEGRAPH_EXECUTABLE: string;
3
4
  declare const ALLOWED_SUBCOMMANDS: readonly ["status", "init", "index", "query", "files", "context", "affected"];
4
5
  type CodegraphSubcommand = (typeof ALLOWED_SUBCOMMANDS)[number];
@@ -28,6 +29,7 @@ export type CodegraphInvocation = {
28
29
  args: string[];
29
30
  cwd: string;
30
31
  packageName: typeof CODEGRAPH_PACKAGE_NAME;
32
+ packageVersion: typeof CODEGRAPH_PACKAGE_VERSION;
31
33
  subcommand: CodegraphSubcommand;
32
34
  };
33
35
  export type CodegraphExecutionResult = {
@@ -2,10 +2,10 @@ import { existsSync, realpathSync, statSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { createRequire } from 'node:module';
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
- const CODEGRAPH_PACKAGE_NAME = '@colbymchenry/codegraph@0.7.10';
5
+ const CODEGRAPH_PACKAGE_NAME = '@colbymchenry/codegraph';
6
+ const CODEGRAPH_PACKAGE_VERSION = '0.7.10';
6
7
  const CODEGRAPH_EXECUTABLE = process.execPath;
7
- const CODEGRAPH_NPX_CLI_PATH = resolveNpxCliPath();
8
- const CODEGRAPH_BINARY_NAME = 'codegraph';
8
+ const CODEGRAPH_BINARY_PATH = resolveCodegraphBinaryPath();
9
9
  const CODEGRAPH_PROCESS_TIMEOUT_MS = 600_000;
10
10
  const CODEGRAPH_OUTPUT_LIMIT_BYTES = 10 * 1024 * 1024;
11
11
  const NODE_OPTIONS_ENV_KEY = 'NODE_OPTIONS';
@@ -24,23 +24,14 @@ const ALLOWED_OPTIONS_BY_SUBCOMMAND = {
24
24
  context: ['task'],
25
25
  affected: ['files', 'json']
26
26
  };
27
- function resolveNpxCliPath() {
27
+ function resolveCodegraphBinaryPath() {
28
28
  const require = createRequire(import.meta.url);
29
- try {
30
- return require.resolve('npm/bin/npx-cli.js');
31
- }
32
- catch {
33
- const nodeBinDirectory = dirname(process.execPath);
34
- const candidatePaths = [
35
- join(nodeBinDirectory, 'node_modules', 'npm', 'bin', 'npx-cli.js'),
36
- resolve(nodeBinDirectory, '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js')
37
- ];
38
- const npxCliPath = candidatePaths.find((candidatePath) => existsSync(candidatePath));
39
- if (npxCliPath === undefined) {
40
- throw new Error('Unable to resolve npm npx-cli.js from the current Node installation');
41
- }
42
- return npxCliPath;
29
+ const packageJsonPath = require.resolve('@colbymchenry/codegraph/package.json');
30
+ const binaryPath = resolve(dirname(packageJsonPath), 'dist', 'bin', 'codegraph.js');
31
+ if (!existsSync(binaryPath)) {
32
+ throw new Error('Unable to resolve local codegraph binary from @colbymchenry/codegraph');
43
33
  }
34
+ return binaryPath;
44
35
  }
45
36
  function assertSupportedSubcommand(subcommand) {
46
37
  if (!ALLOWED_SUBCOMMANDS.includes(subcommand)) {
@@ -126,7 +117,7 @@ function buildAffectedFileArgs(projectRoot, files) {
126
117
  return files.map((file) => normalizeProjectRelativeFile(projectRoot, file));
127
118
  }
128
119
  function buildCommandArgs(options, projectRoot) {
129
- const args = [CODEGRAPH_NPX_CLI_PATH, '--no', '--package', CODEGRAPH_PACKAGE_NAME, CODEGRAPH_BINARY_NAME, options.subcommand];
120
+ const args = [CODEGRAPH_BINARY_PATH, options.subcommand];
130
121
  if (options.subcommand === 'query' && options.search) {
131
122
  args.push(options.search);
132
123
  }
@@ -256,6 +247,7 @@ export function createCodegraphInvocation(options) {
256
247
  args: buildCommandArgs(options, projectRoot),
257
248
  cwd: projectRoot,
258
249
  packageName: CODEGRAPH_PACKAGE_NAME,
250
+ packageVersion: CODEGRAPH_PACKAGE_VERSION,
259
251
  subcommand: options.subcommand
260
252
  };
261
253
  }
@@ -53,8 +53,8 @@ export async function runDoctor(options = {}) {
53
53
  const hasUserConfig = existsSync(userConfigPath);
54
54
  checks.push({
55
55
  id: 'config:user',
56
- ok: hasUserConfig,
57
- message: hasUserConfig ? 'User config exists at ~/.peaks/config.json' : 'User config not found at ~/.peaks/config.json'
56
+ ok: true,
57
+ message: hasUserConfig ? 'User config exists at ~/.peaks/config.json' : 'Optional user config not found at ~/.peaks/config.json'
58
58
  });
59
59
  const failed = checks.filter((check) => !check.ok).length;
60
60
  return {
@@ -0,0 +1,27 @@
1
+ declare const SHADCN_PACKAGE_NAME = "shadcn";
2
+ declare const SHADCN_PACKAGE_VERSION = "4.7.0";
3
+ declare const SHADCN_EXECUTABLE: string;
4
+ export type ShadcnInvocationOptions = {
5
+ args: string[];
6
+ cwd?: string;
7
+ };
8
+ export type ShadcnInvocation = {
9
+ executable: typeof SHADCN_EXECUTABLE;
10
+ args: string[];
11
+ cwd: string;
12
+ packageName: typeof SHADCN_PACKAGE_NAME;
13
+ packageVersion: typeof SHADCN_PACKAGE_VERSION;
14
+ };
15
+ export type ShadcnExecutionResult = {
16
+ exitCode: number | null;
17
+ stdout: string;
18
+ stderr: string;
19
+ };
20
+ export type ShadcnProcessRunner = (invocation: ShadcnInvocation) => Promise<ShadcnExecutionResult>;
21
+ declare function createShadcnEnvironment(sourceEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
22
+ export declare function createShadcnInvocation(options: ShadcnInvocationOptions): ShadcnInvocation;
23
+ export declare function executeShadcnInvocation(invocation: ShadcnInvocation, runner?: ShadcnProcessRunner): Promise<ShadcnExecutionResult>;
24
+ export declare const testInternals: {
25
+ createShadcnEnvironment: typeof createShadcnEnvironment;
26
+ };
27
+ export {};
@@ -0,0 +1,128 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import { resolve } from 'node:path';
5
+ const SHADCN_PACKAGE_NAME = 'shadcn';
6
+ const SHADCN_PACKAGE_VERSION = '4.7.0';
7
+ const SHADCN_EXECUTABLE = process.execPath;
8
+ const SHADCN_BINARY_PATH = resolveShadcnBinaryPath();
9
+ const SHADCN_PROCESS_TIMEOUT_MS = 600_000;
10
+ const SHADCN_OUTPUT_LIMIT_BYTES = 10 * 1024 * 1024;
11
+ const POSITIONAL_ARGUMENT_PREFIX = '-';
12
+ const PRESERVED_ENV_KEYS = ['PATH', 'Path', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'SystemRoot', 'WINDIR'];
13
+ function resolveShadcnBinaryPath() {
14
+ const require = createRequire(import.meta.url);
15
+ const binaryPath = require.resolve('shadcn');
16
+ if (!existsSync(binaryPath)) {
17
+ throw new Error('Unable to resolve local shadcn binary from shadcn');
18
+ }
19
+ return binaryPath;
20
+ }
21
+ function assertShadcnArgs(args) {
22
+ if (args.length === 0) {
23
+ throw new Error('shadcn arguments are required');
24
+ }
25
+ if (args[0]?.startsWith(POSITIONAL_ARGUMENT_PREFIX)) {
26
+ throw new Error('shadcn command must not start with -');
27
+ }
28
+ }
29
+ function createShadcnEnvironment(sourceEnv = process.env) {
30
+ const environment = {};
31
+ for (const key of PRESERVED_ENV_KEYS) {
32
+ const value = sourceEnv[key];
33
+ if (value !== undefined) {
34
+ environment[key] = value;
35
+ }
36
+ }
37
+ return environment;
38
+ }
39
+ function assertOutputLimit(currentSize, chunkSize) {
40
+ const nextSize = currentSize + chunkSize;
41
+ if (nextSize > SHADCN_OUTPUT_LIMIT_BYTES) {
42
+ throw new Error(`shadcn output exceeded ${SHADCN_OUTPUT_LIMIT_BYTES} bytes`);
43
+ }
44
+ return nextSize;
45
+ }
46
+ function terminateShadcnProcess(childProcess) {
47
+ if (childProcess.pid === undefined) {
48
+ childProcess.kill();
49
+ return;
50
+ }
51
+ if (process.platform === 'win32') {
52
+ const taskkillPath = process.env.SystemRoot ? resolve(process.env.SystemRoot, 'System32', 'taskkill.exe') : 'taskkill.exe';
53
+ spawn(taskkillPath, ['/pid', String(childProcess.pid), '/T', '/F'], { shell: false, stdio: 'ignore' });
54
+ return;
55
+ }
56
+ try {
57
+ process.kill(-childProcess.pid, 'SIGTERM');
58
+ }
59
+ catch {
60
+ childProcess.kill('SIGTERM');
61
+ }
62
+ }
63
+ function defaultShadcnProcessRunner(invocation) {
64
+ return new Promise((resolveResult, reject) => {
65
+ const childProcess = spawn(invocation.executable, invocation.args, {
66
+ cwd: invocation.cwd,
67
+ detached: process.platform !== 'win32',
68
+ env: createShadcnEnvironment(),
69
+ shell: false
70
+ });
71
+ const timeout = setTimeout(() => {
72
+ terminateShadcnProcess(childProcess);
73
+ reject(new Error(`shadcn process timed out after ${SHADCN_PROCESS_TIMEOUT_MS}ms`));
74
+ }, SHADCN_PROCESS_TIMEOUT_MS);
75
+ const stdoutChunks = [];
76
+ const stderrChunks = [];
77
+ let stdoutSize = 0;
78
+ let stderrSize = 0;
79
+ childProcess.stdout.on('data', (chunk) => {
80
+ try {
81
+ stdoutSize = assertOutputLimit(stdoutSize, chunk.length);
82
+ stdoutChunks.push(chunk);
83
+ }
84
+ catch (error) {
85
+ terminateShadcnProcess(childProcess);
86
+ reject(error);
87
+ }
88
+ });
89
+ childProcess.stderr.on('data', (chunk) => {
90
+ try {
91
+ stderrSize = assertOutputLimit(stderrSize, chunk.length);
92
+ stderrChunks.push(chunk);
93
+ }
94
+ catch (error) {
95
+ terminateShadcnProcess(childProcess);
96
+ reject(error);
97
+ }
98
+ });
99
+ childProcess.on('error', (error) => {
100
+ clearTimeout(timeout);
101
+ reject(error);
102
+ });
103
+ childProcess.on('close', (exitCode) => {
104
+ clearTimeout(timeout);
105
+ resolveResult({
106
+ exitCode,
107
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
108
+ stderr: Buffer.concat(stderrChunks).toString('utf8')
109
+ });
110
+ });
111
+ });
112
+ }
113
+ export function createShadcnInvocation(options) {
114
+ assertShadcnArgs(options.args);
115
+ return {
116
+ executable: SHADCN_EXECUTABLE,
117
+ args: [SHADCN_BINARY_PATH, ...options.args],
118
+ cwd: options.cwd ?? process.cwd(),
119
+ packageName: SHADCN_PACKAGE_NAME,
120
+ packageVersion: SHADCN_PACKAGE_VERSION
121
+ };
122
+ }
123
+ export async function executeShadcnInvocation(invocation, runner = defaultShadcnProcessRunner) {
124
+ return runner(invocation);
125
+ }
126
+ export const testInternals = {
127
+ createShadcnEnvironment
128
+ };
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.0.5";
1
+ export declare const CLI_VERSION = "1.0.7";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.0.5";
1
+ export const CLI_VERSION = "1.0.7";
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Peaks CLI and short skill family for Claude Code automation.",
5
5
  "author": "SquabbyZ",
6
- "license": "SEE LICENSE IN LICENSE",
6
+ "license": "MIT",
7
7
  "type": "module",
8
8
  "packageManager": "pnpm@10.11.0",
9
9
  "publishConfig": {
@@ -41,7 +41,9 @@
41
41
  "node": ">=20.0.0"
42
42
  },
43
43
  "dependencies": {
44
- "commander": "^12.1.0"
44
+ "@colbymchenry/codegraph": "0.7.10",
45
+ "commander": "^12.1.0",
46
+ "shadcn": "4.7.0"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@types/node": "^22.10.2",
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { closeSync, constants, copyFileSync, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join, resolve } from 'node:path';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
 
7
7
  function getPathStats(path) {
@@ -38,6 +38,200 @@ function createInstallResult() {
38
38
  return { installed: [], skipped: [] };
39
39
  }
40
40
 
41
+ const PROJECT_CONFIG_DEFAULTS = {
42
+ version: '0.1.0',
43
+ currentWorkspace: null,
44
+ workspaces: [],
45
+ language: 'en',
46
+ model: 'sonnet',
47
+ economyMode: true,
48
+ swarmMode: true,
49
+ tokens: {},
50
+ providers: {
51
+ minimax: {
52
+ model: 'minimax-2.7'
53
+ }
54
+ },
55
+ proxy: {}
56
+ };
57
+
58
+ function createConfigResult(overrides = {}) {
59
+ return { created: false, updated: false, skipped: false, ...overrides };
60
+ }
61
+
62
+ function isInsidePath(childPath, parentPath) {
63
+ const relativePath = relative(parentPath, childPath);
64
+ return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
65
+ }
66
+
67
+ function isPlainObject(value) {
68
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
69
+ }
70
+
71
+ function mergeMissingConfigValues(existing, defaults) {
72
+ return Object.entries(defaults).reduce((next, [key, defaultValue]) => {
73
+ if (!(key in next)) {
74
+ return { ...next, [key]: defaultValue };
75
+ }
76
+
77
+ const existingValue = next[key];
78
+ if (isPlainObject(existingValue) && isPlainObject(defaultValue)) {
79
+ return { ...next, [key]: mergeMissingConfigValues(existingValue, defaultValue) };
80
+ }
81
+
82
+ return next;
83
+ }, { ...existing });
84
+ }
85
+
86
+ function readConfigFile(configPath, label) {
87
+ if (!existsSync(configPath)) {
88
+ return null;
89
+ }
90
+
91
+ try {
92
+ const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
93
+ if (!isPlainObject(parsed)) {
94
+ throw new Error(`${label} config must contain a JSON object`);
95
+ }
96
+
97
+ return parsed;
98
+ } catch (error) {
99
+ const message = error instanceof SyntaxError ? `${label} config must contain valid JSON` : error instanceof Error ? error.message : String(error);
100
+ throw new Error(message);
101
+ }
102
+ }
103
+
104
+ function validateConfigPath(root, peaksRoot, configPath, label) {
105
+ const rootReal = realpathSync(root);
106
+ const peaksStats = lstatSync(peaksRoot);
107
+ const peaksReal = realpathSync(peaksRoot);
108
+ if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(rootReal, '.peaks')) {
109
+ throw new Error(`${label} config path must stay inside the ${label.toLowerCase()} root`);
110
+ }
111
+
112
+ const configStats = getPathStats(configPath);
113
+ if (configStats?.isSymbolicLink()) {
114
+ throw new Error(`${label} config path must not be a symlink`);
115
+ }
116
+ if (configStats && !configStats.isFile()) {
117
+ throw new Error(`${label} config path must be a file`);
118
+ }
119
+ if (configStats) {
120
+ const configReal = realpathSync(configPath);
121
+ if (!isInsidePath(configReal, rootReal) || !isInsidePath(configReal, peaksReal)) {
122
+ throw new Error(`${label} config path must stay inside the ${label.toLowerCase()} root`);
123
+ }
124
+ }
125
+ }
126
+
127
+ function validateProjectConfigPaths(projectRoot, peaksRoot, configPath) {
128
+ validateConfigPath(projectRoot, peaksRoot, configPath, 'Project');
129
+ }
130
+
131
+ function validateUserConfigPaths(userRoot, peaksRoot, configPath) {
132
+ validateConfigPath(userRoot, peaksRoot, configPath, 'User');
133
+ }
134
+
135
+ function validateOpenConfigFile(fd, configPath, label) {
136
+ const fdStats = fstatSync(fd);
137
+ const pathStats = lstatSync(configPath);
138
+ if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
139
+ throw new Error(`${label} config path changed during write`);
140
+ }
141
+ if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
142
+ throw new Error(`${label} config path must not be hardlinked`);
143
+ }
144
+ }
145
+
146
+ function writeConfigFile(configPath, content, label, validateBeforeWrite) {
147
+ validateBeforeWrite();
148
+ if (typeof constants.O_NOFOLLOW !== 'number') {
149
+ throw new Error('Safe config writes require O_NOFOLLOW support');
150
+ }
151
+
152
+ const fd = openSync(configPath, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
153
+ try {
154
+ validateBeforeWrite();
155
+ validateOpenConfigFile(fd, configPath, label);
156
+ fchmodSync(fd, 0o600);
157
+ ftruncateSync(fd, 0);
158
+ writeFileSync(fd, content, 'utf8');
159
+ } finally {
160
+ closeSync(fd);
161
+ }
162
+ }
163
+
164
+ function writeProjectConfig(projectRoot, peaksRoot, configPath, content) {
165
+ writeConfigFile(configPath, content, 'Project', () => validateProjectConfigPaths(projectRoot, peaksRoot, configPath));
166
+ }
167
+
168
+ function writeUserConfig(userRoot, peaksRoot, configPath, content) {
169
+ writeConfigFile(configPath, content, 'User', () => validateUserConfigPaths(userRoot, peaksRoot, configPath));
170
+ }
171
+
172
+ function resolveProjectRoot(options) {
173
+ const projectRoot = options.projectRoot ?? process.env.PEAKS_PROJECT_ROOT ?? process.env.INIT_CWD;
174
+ return projectRoot ? resolve(projectRoot) : null;
175
+ }
176
+
177
+ function writeMergedConfig(configPath, label, writeConfig) {
178
+ const existing = readConfigFile(configPath, label);
179
+ const next = existing === null ? PROJECT_CONFIG_DEFAULTS : mergeMissingConfigValues(existing, PROJECT_CONFIG_DEFAULTS);
180
+ const currentJson = existing === null ? null : `${JSON.stringify(existing, null, 2)}\n`;
181
+ const nextJson = `${JSON.stringify(next, null, 2)}\n`;
182
+
183
+ if (currentJson === nextJson) {
184
+ return createConfigResult();
185
+ }
186
+
187
+ writeConfig(nextJson);
188
+ return createConfigResult(existing === null ? { created: true } : { updated: true });
189
+ }
190
+
191
+ export function installUserConfig(options = {}) {
192
+ if (process.env.PEAKS_SKIP_SKILL_INSTALL === '1' || process.env.PEAKS_SKIP_USER_CONFIG_INSTALL === '1') {
193
+ return createConfigResult({ skipped: true });
194
+ }
195
+
196
+ const userRoot = resolve(options.userRoot ?? homedir());
197
+ const peaksRoot = resolve(userRoot, '.peaks');
198
+ const configPath = resolve(peaksRoot, 'config.json');
199
+ if (!isInsidePath(configPath, userRoot)) {
200
+ throw new Error('User config path must stay inside the user root');
201
+ }
202
+
203
+ if (!existsSync(peaksRoot)) {
204
+ mkdirSync(peaksRoot, { recursive: true });
205
+ }
206
+ validateUserConfigPaths(userRoot, peaksRoot, configPath);
207
+
208
+ return writeMergedConfig(configPath, 'User', (content) => writeUserConfig(userRoot, peaksRoot, configPath, content));
209
+ }
210
+
211
+ export function installProjectConfig(options = {}) {
212
+ if (process.env.PEAKS_SKIP_SKILL_INSTALL === '1' || process.env.PEAKS_SKIP_PROJECT_CONFIG_INSTALL === '1') {
213
+ return createConfigResult({ skipped: true });
214
+ }
215
+
216
+ const projectRoot = resolveProjectRoot(options);
217
+ if (!projectRoot) {
218
+ return createConfigResult({ skipped: true });
219
+ }
220
+
221
+ const peaksRoot = resolve(projectRoot, '.peaks');
222
+ const configPath = resolve(peaksRoot, 'config.json');
223
+ if (!isInsidePath(configPath, projectRoot)) {
224
+ throw new Error('Project config path must stay inside the project root');
225
+ }
226
+
227
+ if (!existsSync(peaksRoot)) {
228
+ mkdirSync(peaksRoot, { recursive: true });
229
+ }
230
+ validateProjectConfigPaths(projectRoot, peaksRoot, configPath);
231
+
232
+ return writeMergedConfig(configPath, 'Project', (content) => writeProjectConfig(projectRoot, peaksRoot, configPath, content));
233
+ }
234
+
41
235
  export function installBundledSkills(options = {}) {
42
236
  const packageRoot = resolve(options.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..'));
43
237
  const skillsRoot = join(packageRoot, 'skills');
@@ -129,6 +323,13 @@ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(p
129
323
  try {
130
324
  const skillsResult = installBundledSkills();
131
325
  const outputStylesResult = installBundledOutputStyles();
326
+ let userConfigResult = createConfigResult({ skipped: true });
327
+ try {
328
+ userConfigResult = installUserConfig();
329
+ } catch (error) {
330
+ const message = error instanceof Error ? error.message : String(error);
331
+ process.stderr.write(`Peaks user config was not installed: ${message}\n`);
332
+ }
132
333
  if (skillsResult.installed.length > 0) {
133
334
  process.stdout.write(`Peaks skills linked: ${skillsResult.installed.join(', ')}\n`);
134
335
  }
@@ -141,6 +342,12 @@ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(p
141
342
  if (outputStylesResult.skipped.length > 0) {
142
343
  process.stderr.write(`Peaks output styles skipped because local files already exist: ${outputStylesResult.skipped.join(', ')}\n`);
143
344
  }
345
+ if (userConfigResult.created) {
346
+ process.stdout.write('Peaks user config created: ~/.peaks/config.json\n');
347
+ }
348
+ if (userConfigResult.updated) {
349
+ process.stdout.write('Peaks user config updated: ~/.peaks/config.json\n');
350
+ }
144
351
  } catch (error) {
145
352
  const message = error instanceof Error ? error.message : String(error);
146
353
  process.stderr.write(`Peaks skills and output styles were not installed: ${message}\n`);
@@ -55,7 +55,7 @@ QA cannot pass a change until the report contains evidence for every applicable
55
55
 
56
56
  1. **Unit tests** — run the project test command or a focused test command that covers new/changed code. For legacy projects below the target coverage, require coverage for the new or changed code rather than failing on pre-existing uncovered code.
57
57
  2. **API validation** — when the change touches API contracts, data loading, request handling, auth, or integrations, exercise the relevant API path and record request/response evidence or a justified local substitute.
58
- 3. **Frontend browser validation** — when the repository has a frontend or the change affects UI, launch the app and use `gstack/browse/dist/browse` for real browser end-to-end validation. Prefer headed or handoff mode so a visible browser actually opens; verify with `browse status`, `browse focus`, screenshot, or user confirmation when needed. Capture the route, actions, screenshots or observations, console errors, network failures, and acceptance result.
58
+ 3. **Frontend browser validation** — when the repository has a frontend or the change affects UI, launch the app and use `gstack/browse/dist/browse` for real browser end-to-end validation. Use headed or handoff mode by default so a visible browser actually opens; verify the visible browser with `browse status`, screenshot evidence, or user confirmation. Do not call Playwright MCP for browser validation. Capture the route, actions, screenshots or observations, console errors, network failures, and acceptance result.
59
59
  4. **Browser-error feedback loop** — if `gstack/browse/dist/browse` shows a page error, console exception, broken network request, hydration/render failure, or visible regression, return the work to RD/development with the exact evidence. Do not pass QA until the fixed build is retested in the browser.
60
60
  5. **Security check** — run security review for the changed surface and dependency/config changes. Record findings, fixes, and unresolved risks.
61
61
  6. **Performance check** — run the project’s available performance check, build-size check, Lighthouse-equivalent check, or browser performance inspection appropriate to the change. Record baseline/after numbers when available.
@@ -89,9 +89,9 @@ External analysis cannot pass QA by itself. Treat codegraph output as untrusted
89
89
 
90
90
  ## External capability guidance
91
91
 
92
- Use `peaks capabilities --source access-repo --json` before recommending browser or validation MCPs.
92
+ Use `peaks capabilities --source access-repo --json` before recommending browser or validation tooling.
93
93
 
94
- - Playwright MCP can support controlled browser and E2E validation after the target app and environment are approved.
94
+ - Headed gstack browse is the default for controlled browser and E2E validation after the target app and environment are approved; confirm a visible browser opened.
95
95
  - Chrome DevTools MCP can support console, network, accessibility, and performance inspection for QA evidence.
96
96
  - Agent Browser can support browser walkthroughs, but never submit forms, purchase, delete, or mutate authenticated state without explicit confirmation.
97
97
  - If browser automation is unavailable, fallback to local Playwright, screenshots, logs, and manual regression steps only as diagnostic evidence or an explicitly approved exception; do not count it as a passed frontend browser gate by default.
@@ -93,7 +93,7 @@ Peaks PRD/RD/QA gates remain authoritative: OpenSpec structures the durable spec
93
93
 
94
94
  When RD work creates a frontend application and the user has not specified a technology stack, and the current scan plus existing project standards still do not establish a frontend stack, default to React + Vite + shadcn/ui with:
95
95
 
96
- - `pnpm dlx shadcn@latest init --preset [CODE] --template vite`
96
+ - `peaks shadcn init --preset [CODE] --template vite`
97
97
 
98
98
  `[CODE]` is the preset code supplied by the shadcn registry or user workflow; if it is unknown, stop and resolve the intended preset before scaffolding.
99
99