peaks-cli 1.1.0 → 1.1.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.
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerStatusLineCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,111 @@
1
+ import { fail, ok } from '../../shared/result.js';
2
+ import { addJsonOption, printResult, getErrorMessage } from '../cli-helpers.js';
3
+ import { findProjectRoot } from '../../services/config/config-safety.js';
4
+ import { buildStatusLineModel, parseStatusLineStdin } from '../../services/skills/skill-statusline-service.js';
5
+ import { renderStatusLine } from '../../services/skills/skill-statusline-renderer.js';
6
+ import { applyStatusLineInstall, planStatusLineInstall, removeStatusLineInstall } from '../../services/skills/statusline-settings-service.js';
7
+ const STDIN_READ_TIMEOUT_MS = 250;
8
+ /** Read piped stdin if present; resolve quickly with '' when attached to a TTY. */
9
+ function readStdin() {
10
+ return new Promise((resolve) => {
11
+ if (process.stdin.isTTY) {
12
+ resolve('');
13
+ return;
14
+ }
15
+ let data = '';
16
+ let settled = false;
17
+ const finish = () => {
18
+ if (settled)
19
+ return;
20
+ settled = true;
21
+ resolve(data);
22
+ };
23
+ const timer = setTimeout(finish, STDIN_READ_TIMEOUT_MS);
24
+ timer.unref?.();
25
+ process.stdin.setEncoding('utf8');
26
+ process.stdin.on('data', (chunk) => {
27
+ data += chunk;
28
+ });
29
+ process.stdin.on('end', () => {
30
+ clearTimeout(timer);
31
+ finish();
32
+ });
33
+ process.stdin.on('error', () => {
34
+ clearTimeout(timer);
35
+ finish();
36
+ });
37
+ });
38
+ }
39
+ function resolveScope(options) {
40
+ return options.global ? 'global' : 'project';
41
+ }
42
+ export function registerStatusLineCommands(program, io) {
43
+ const statusline = program
44
+ .command('statusline')
45
+ .description('Render the Peaks skill status line for Claude Code (reads session JSON on stdin)')
46
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
47
+ .action(async (options) => {
48
+ const raw = await readStdin();
49
+ const stdin = parseStatusLineStdin(raw);
50
+ // When a project override is passed (or no stdin), seed cwd so detection works.
51
+ const seeded = options.project
52
+ ? { ...(stdin ?? {}), workspace: { current_dir: options.project } }
53
+ : stdin;
54
+ const model = buildStatusLineModel(seeded, Date.now());
55
+ io.stdout(renderStatusLine(model));
56
+ });
57
+ addJsonOption(statusline
58
+ .command('install')
59
+ .description('Install the Peaks status line into .claude/settings.json (project scope by default)')
60
+ .option('--global', 'install into the user-level ~/.claude/settings.json instead of the project')
61
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
62
+ .option('--force', 'overwrite an existing non-Peaks statusLine entry')
63
+ .option('--dry-run', 'show what would change without writing')).action((options) => {
64
+ const scope = resolveScope(options);
65
+ const projectRoot = scope === 'project'
66
+ ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
67
+ : undefined;
68
+ try {
69
+ if (options.dryRun) {
70
+ const plan = planStatusLineInstall(scope, projectRoot);
71
+ const warnings = plan.conflict
72
+ ? [`An existing statusLine command is set: ${plan.conflictCommand}. Rerun with --force to overwrite.`]
73
+ : [];
74
+ printResult(io, ok('statusline.install', { ...plan, applied: false, dryRun: true }, warnings), options.json);
75
+ return;
76
+ }
77
+ const result = applyStatusLineInstall(scope, projectRoot, options.force ? { force: true } : {});
78
+ const warnings = result.conflict && !result.applied
79
+ ? [`An existing statusLine command is set: ${result.conflictCommand}. Rerun with --force to overwrite.`]
80
+ : [];
81
+ const nextActions = result.applied
82
+ ? ['Restart Claude Code (or reload the window) so the status line takes effect']
83
+ : [];
84
+ printResult(io, ok('statusline.install', { ...result, dryRun: false }, warnings, nextActions), options.json);
85
+ }
86
+ catch (error) {
87
+ const message = getErrorMessage(error);
88
+ printResult(io, fail('statusline.install', 'STATUSLINE_INSTALL_FAILED', message, { scope, applied: false }, [message]), options.json);
89
+ process.exitCode = 1;
90
+ }
91
+ });
92
+ addJsonOption(statusline
93
+ .command('uninstall')
94
+ .description('Remove the Peaks status line from .claude/settings.json')
95
+ .option('--global', 'remove from the user-level ~/.claude/settings.json instead of the project')
96
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((options) => {
97
+ const scope = resolveScope(options);
98
+ const projectRoot = scope === 'project'
99
+ ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
100
+ : undefined;
101
+ try {
102
+ const result = removeStatusLineInstall(scope, projectRoot);
103
+ printResult(io, ok('statusline.uninstall', result), options.json);
104
+ }
105
+ catch (error) {
106
+ const message = getErrorMessage(error);
107
+ printResult(io, fail('statusline.uninstall', 'STATUSLINE_UNINSTALL_FAILED', message, { scope, removed: false }, [message]), options.json);
108
+ process.exitCode = 1;
109
+ }
110
+ });
111
+ }
@@ -10,6 +10,7 @@ import { registerProjectCommands } from './commands/project-commands.js';
10
10
  import { registerRequestCommands } from './commands/request-commands.js';
11
11
  import { registerScanCommands } from './commands/scan-commands.js';
12
12
  import { registerShadcnCommands } from './commands/shadcn-commands.js';
13
+ import { registerStatusLineCommands } from './commands/statusline-commands.js';
13
14
  import { registerUnderstandCommands } from './commands/understand-commands.js';
14
15
  import { registerWorkspaceCommands } from './commands/workspace-commands.js';
15
16
  export { printResult } from './cli-helpers.js';
@@ -40,6 +41,7 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
40
41
  registerRequestCommands(program, io);
41
42
  registerScanCommands(program, io);
42
43
  registerShadcnCommands(program, io);
44
+ registerStatusLineCommands(program, io);
43
45
  registerUnderstandCommands(program, io);
44
46
  registerWorkspaceCommands(program, io);
45
47
  return program;
@@ -24,5 +24,6 @@ export type DoctorOptions = {
24
24
  codegraphProbe?: () => CodegraphCapabilityProbe;
25
25
  skillPresenceProbe?: () => SkillPresence | null;
26
26
  skillPresenceFreshnessThresholdMs?: number;
27
+ statusLineInstalledProbe?: () => boolean;
27
28
  };
28
29
  export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
@@ -8,6 +8,8 @@ import { requiredSchemaFiles, requiredSkillNames, schemasDir } from '../../share
8
8
  import { getErrorMessage } from '../../shared/result.js';
9
9
  import { loadSkillRegistry } from '../skills/skill-registry.js';
10
10
  import { getSkillPresence } from '../skills/skill-presence-service.js';
11
+ import { planStatusLineInstall } from '../skills/statusline-settings-service.js';
12
+ import { findProjectRoot } from '../config/config-safety.js';
11
13
  const CODEGRAPH_EXPECTED_VERSION = '0.7.10';
12
14
  const SKILL_PRESENCE_FRESHNESS_THRESHOLD_MS = 24 * 60 * 60 * 1000;
13
15
  function defaultCodegraphProbe() {
@@ -22,6 +24,17 @@ function defaultCodegraphProbe() {
22
24
  binaryExists: existsSync(binaryPath)
23
25
  };
24
26
  }
27
+ function defaultStatusLineInstalledProbe() {
28
+ const projectRoot = findProjectRoot(process.cwd());
29
+ if (projectRoot === null)
30
+ return false;
31
+ try {
32
+ return planStatusLineInstall('project', projectRoot).alreadyInstalled;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
25
38
  const DESTRUCTIVE_APPLY_PATTERNS = [
26
39
  /peaks\s+memory\s+sync[^\n]*--apply/,
27
40
  /peaks\s+memory\s+extract[^\n]*--apply/,
@@ -190,6 +203,33 @@ export async function runDoctor(options = {}) {
190
203
  }
191
204
  }
192
205
  }
206
+ // Discoverability nudge: when a skill is actively orchestrating but the
207
+ // out-of-band statusLine isn't installed, the user has no terminal-level
208
+ // signal that Peaks is in control. Suggest installing it (non-failing).
209
+ const statusLineProbe = options.statusLineInstalledProbe ?? defaultStatusLineInstalledProbe;
210
+ let statusLineInstalled = false;
211
+ try {
212
+ statusLineInstalled = statusLineProbe();
213
+ }
214
+ catch {
215
+ statusLineInstalled = false;
216
+ }
217
+ if (presence !== null && !statusLineInstalled) {
218
+ checks.push({
219
+ id: 'statusline:install',
220
+ ok: true,
221
+ message: 'A Peaks skill is active but the statusLine is not installed; run `peaks statusline install` so the active skill shows in the terminal status bar'
222
+ });
223
+ }
224
+ else {
225
+ checks.push({
226
+ id: 'statusline:install',
227
+ ok: true,
228
+ message: statusLineInstalled
229
+ ? 'Peaks statusLine is installed'
230
+ : 'Peaks statusLine not installed (no active skill; install optional)'
231
+ });
232
+ }
193
233
  const probe = options.codegraphProbe ?? defaultCodegraphProbe;
194
234
  try {
195
235
  const result = probe();
@@ -0,0 +1,6 @@
1
+ import type { StatusLineModel } from './skill-statusline-service.js';
2
+ /**
3
+ * Render the status line. The output is plain text with simple status glyphs so
4
+ * it stays readable in any terminal; Claude Code applies its own styling.
5
+ */
6
+ export declare function renderStatusLine(model: StatusLineModel): string;
@@ -0,0 +1,55 @@
1
+ import { basename } from 'node:path';
2
+ /**
3
+ * Pure formatting layer for the Peaks statusLine. Takes the read-only status
4
+ * model and produces the single line Claude Code paints at the bottom of the
5
+ * terminal. Kept separate from the reader so formatting can be tested without
6
+ * touching the filesystem.
7
+ */
8
+ const BRAND = '⛰ Peaks';
9
+ function formatAge(ageMs) {
10
+ if (ageMs === null)
11
+ return '';
12
+ const hours = Math.round(ageMs / (60 * 60 * 1000));
13
+ if (hours >= 1)
14
+ return `stale ${hours}h`;
15
+ const minutes = Math.max(1, Math.round(ageMs / (60 * 1000)));
16
+ return `stale ${minutes}m`;
17
+ }
18
+ function rootLabel(projectRoot) {
19
+ if (!projectRoot)
20
+ return '';
21
+ return basename(projectRoot);
22
+ }
23
+ /**
24
+ * Render the status line. The output is plain text with simple status glyphs so
25
+ * it stays readable in any terminal; Claude Code applies its own styling.
26
+ */
27
+ export function renderStatusLine(model) {
28
+ const root = rootLabel(model.projectRoot);
29
+ const rootSuffix = root ? ` · ${root}` : '';
30
+ switch (model.state) {
31
+ case 'active': {
32
+ const presence = model.presence;
33
+ if (!presence)
34
+ return `${BRAND} ○ idle${rootSuffix}`;
35
+ const parts = [presence.skill];
36
+ if (presence.mode)
37
+ parts.push(presence.mode);
38
+ if (presence.gate)
39
+ parts.push(`gate:${presence.gate}`);
40
+ return `${BRAND} ● ${parts.join(' · ')}${rootSuffix}`;
41
+ }
42
+ case 'stale': {
43
+ const presence = model.presence;
44
+ const skill = presence?.skill ?? 'unknown';
45
+ const age = formatAge(model.ageMs);
46
+ const ageSuffix = age ? ` · ${age}` : '';
47
+ return `${BRAND} ⚠ ${skill}${ageSuffix}${rootSuffix}`;
48
+ }
49
+ case 'invalid-presence':
50
+ return `${BRAND} ⚠ presence file unreadable${rootSuffix}`;
51
+ case 'idle':
52
+ default:
53
+ return `${BRAND} ○ idle${rootSuffix}`;
54
+ }
55
+ }
@@ -0,0 +1,22 @@
1
+ export type StatusLineStdin = {
2
+ workspace?: {
3
+ current_dir?: string;
4
+ project_dir?: string;
5
+ };
6
+ cwd?: string;
7
+ };
8
+ export type StatusLineState = 'active' | 'idle' | 'stale' | 'invalid-presence';
9
+ export type StatusLinePresence = {
10
+ skill: string;
11
+ mode?: string;
12
+ gate?: string;
13
+ setAt?: string;
14
+ };
15
+ export type StatusLineModel = {
16
+ state: StatusLineState;
17
+ projectRoot: string | null;
18
+ presence: StatusLinePresence | null;
19
+ ageMs: number | null;
20
+ };
21
+ export declare function parseStatusLineStdin(raw: string): StatusLineStdin | null;
22
+ export declare function buildStatusLineModel(stdin: StatusLineStdin | null, nowMs: number): StatusLineModel;
@@ -0,0 +1,94 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { findProjectRoot } from '../config/config-safety.js';
4
+ /**
5
+ * Out-of-band Peaks skill status renderer for the Claude Code statusLine.
6
+ *
7
+ * Claude Code invokes the configured statusLine command on every turn and pipes
8
+ * a JSON session payload on stdin. This renderer reads the durable presence file
9
+ * (.peaks/.active-skill.json) and prints a single line that Claude Code paints at
10
+ * the bottom of the terminal. Because it is rendered by the harness — not emitted
11
+ * as LLM tokens — the signal cannot be forgotten by the model, cannot be confused
12
+ * with normal output, and survives context compaction.
13
+ *
14
+ * This module is intentionally READ-ONLY. Unlike getSkillPresence in
15
+ * skill-presence-service.ts, it never deletes or rewrites the presence file:
16
+ * the statusLine runs on every turn and must have zero side effects.
17
+ */
18
+ const PRESENCE_FILE = '.peaks/.active-skill.json';
19
+ const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
20
+ function resolveCwdFromStdin(stdin) {
21
+ const fromWorkspace = stdin?.workspace?.current_dir ?? stdin?.workspace?.project_dir;
22
+ if (typeof fromWorkspace === 'string' && fromWorkspace.length > 0) {
23
+ return resolve(fromWorkspace);
24
+ }
25
+ if (typeof stdin?.cwd === 'string' && stdin.cwd.length > 0) {
26
+ return resolve(stdin.cwd);
27
+ }
28
+ return process.cwd();
29
+ }
30
+ export function parseStatusLineStdin(raw) {
31
+ const trimmed = raw.trim();
32
+ if (trimmed.length === 0)
33
+ return null;
34
+ try {
35
+ const parsed = JSON.parse(trimmed);
36
+ if (parsed && typeof parsed === 'object') {
37
+ return parsed;
38
+ }
39
+ return null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * Read the presence file without any side effects. Returns null when the file is
47
+ * absent (idle) and a sentinel object for malformed content (invalid-presence).
48
+ */
49
+ function readPresenceReadOnly(projectRoot) {
50
+ const presencePath = resolve(projectRoot, PRESENCE_FILE);
51
+ if (!existsSync(presencePath)) {
52
+ return { presence: null, invalid: false };
53
+ }
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(presencePath, 'utf8'));
56
+ if (!parsed || typeof parsed !== 'object') {
57
+ return { presence: null, invalid: true };
58
+ }
59
+ const candidate = parsed;
60
+ if (typeof candidate.skill !== 'string' || candidate.skill.length === 0) {
61
+ return { presence: null, invalid: true };
62
+ }
63
+ return {
64
+ presence: {
65
+ skill: candidate.skill,
66
+ ...(typeof candidate.mode === 'string' ? { mode: candidate.mode } : {}),
67
+ ...(typeof candidate.gate === 'string' ? { gate: candidate.gate } : {}),
68
+ ...(typeof candidate.setAt === 'string' ? { setAt: candidate.setAt } : {})
69
+ },
70
+ invalid: false
71
+ };
72
+ }
73
+ catch {
74
+ return { presence: null, invalid: true };
75
+ }
76
+ }
77
+ export function buildStatusLineModel(stdin, nowMs) {
78
+ const cwd = resolveCwdFromStdin(stdin);
79
+ const projectRoot = findProjectRoot(cwd);
80
+ if (projectRoot === null) {
81
+ return { state: 'idle', projectRoot: null, presence: null, ageMs: null };
82
+ }
83
+ const { presence, invalid } = readPresenceReadOnly(projectRoot);
84
+ if (invalid) {
85
+ return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null };
86
+ }
87
+ if (presence === null) {
88
+ return { state: 'idle', projectRoot, presence: null, ageMs: null };
89
+ }
90
+ const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
91
+ const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
92
+ const state = ageMs !== null && ageMs > STALE_THRESHOLD_MS ? 'stale' : 'active';
93
+ return { state, projectRoot, presence, ageMs };
94
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Installs (and removes) the Peaks statusLine entry in a Claude Code
3
+ * settings.json. The statusLine renders `peaks statusline` on every turn, giving
4
+ * users an out-of-band, harness-painted signal of which Peaks skill is active —
5
+ * independent of LLM tokens and immune to context compaction.
6
+ *
7
+ * Writes preserve all other settings keys, reject symlinked targets, and use an
8
+ * atomic rename so a partial write can never corrupt an existing settings file.
9
+ */
10
+ export type StatusLineScope = 'project' | 'global';
11
+ export type StatusLineSettingsPlan = {
12
+ scope: StatusLineScope;
13
+ settingsPath: string;
14
+ exists: boolean;
15
+ alreadyInstalled: boolean;
16
+ conflict: boolean;
17
+ conflictCommand: string | null;
18
+ desiredCommand: string;
19
+ };
20
+ export type StatusLineSettingsResult = StatusLineSettingsPlan & {
21
+ applied: boolean;
22
+ };
23
+ export declare const STATUSLINE_COMMAND = "peaks statusline";
24
+ export declare function planStatusLineInstall(scope: StatusLineScope, projectRoot?: string): StatusLineSettingsPlan;
25
+ export declare function applyStatusLineInstall(scope: StatusLineScope, projectRoot?: string, options?: {
26
+ force?: boolean;
27
+ }): StatusLineSettingsResult;
28
+ export declare function removeStatusLineInstall(scope: StatusLineScope, projectRoot?: string): {
29
+ scope: StatusLineScope;
30
+ settingsPath: string;
31
+ removed: boolean;
32
+ };
@@ -0,0 +1,144 @@
1
+ import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ export const STATUSLINE_COMMAND = 'peaks statusline';
6
+ function isInsidePath(childPath, parentPath) {
7
+ const rel = relative(parentPath, childPath);
8
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
9
+ }
10
+ function resolveSettingsRoot(scope, projectRoot) {
11
+ if (scope === 'global')
12
+ return resolve(homedir());
13
+ if (!projectRoot) {
14
+ throw new Error('Project scope requires a project root');
15
+ }
16
+ return resolve(projectRoot);
17
+ }
18
+ function resolveSettingsPath(scope, projectRoot) {
19
+ const root = resolveSettingsRoot(scope, projectRoot);
20
+ return join(root, '.claude', 'settings.json');
21
+ }
22
+ /** Reject symlinked .claude dir or settings file to prevent escape. */
23
+ function assertSafeSettingsPath(scope, root, settingsPath) {
24
+ const claudeDir = join(root, '.claude');
25
+ if (existsSync(claudeDir) && lstatSync(claudeDir).isSymbolicLink()) {
26
+ throw new Error('.claude directory must not be a symlink');
27
+ }
28
+ if (existsSync(settingsPath)) {
29
+ if (lstatSync(settingsPath).isSymbolicLink()) {
30
+ throw new Error('settings.json must not be a symlink');
31
+ }
32
+ const realRoot = realpathSync(root);
33
+ if (!isInsidePath(realpathSync(settingsPath), realRoot)) {
34
+ throw new Error(`settings.json must stay inside the ${scope} root`);
35
+ }
36
+ }
37
+ }
38
+ function readSettings(settingsPath) {
39
+ if (!existsSync(settingsPath))
40
+ return {};
41
+ const fd = openSync(settingsPath, constants.O_RDONLY | constants.O_NOFOLLOW);
42
+ try {
43
+ const raw = readFileSync(fd, 'utf8').trim();
44
+ if (raw.length === 0)
45
+ return {};
46
+ const parsed = JSON.parse(raw);
47
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
48
+ throw new Error('settings.json must contain a JSON object');
49
+ }
50
+ return parsed;
51
+ }
52
+ finally {
53
+ closeSync(fd);
54
+ }
55
+ }
56
+ function extractExistingCommand(settings) {
57
+ const statusLine = settings.statusLine;
58
+ if (statusLine && typeof statusLine === 'object' && !Array.isArray(statusLine)) {
59
+ const command = statusLine.command;
60
+ if (typeof command === 'string')
61
+ return command;
62
+ }
63
+ return null;
64
+ }
65
+ function buildPlan(scope, settingsPath, settings, exists) {
66
+ const existingCommand = extractExistingCommand(settings);
67
+ const alreadyInstalled = existingCommand !== null && existingCommand.includes(STATUSLINE_COMMAND);
68
+ const conflict = existingCommand !== null && !alreadyInstalled;
69
+ return {
70
+ scope,
71
+ settingsPath,
72
+ exists,
73
+ alreadyInstalled,
74
+ conflict,
75
+ conflictCommand: conflict ? existingCommand : null,
76
+ desiredCommand: STATUSLINE_COMMAND
77
+ };
78
+ }
79
+ export function planStatusLineInstall(scope, projectRoot) {
80
+ const root = resolveSettingsRoot(scope, projectRoot);
81
+ const settingsPath = resolveSettingsPath(scope, projectRoot);
82
+ assertSafeSettingsPath(scope, root, settingsPath);
83
+ const exists = existsSync(settingsPath);
84
+ const settings = readSettings(settingsPath);
85
+ return buildPlan(scope, settingsPath, settings, exists);
86
+ }
87
+ function atomicWriteJson(settingsPath, settings) {
88
+ const dir = dirname(settingsPath);
89
+ mkdirSync(dir, { recursive: true });
90
+ const tempPath = join(dir, `.settings.${randomUUID()}.tmp`);
91
+ const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
92
+ try {
93
+ writeFileSync(fd, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
94
+ }
95
+ finally {
96
+ closeSync(fd);
97
+ }
98
+ try {
99
+ renameSync(tempPath, settingsPath);
100
+ }
101
+ catch (error) {
102
+ try {
103
+ unlinkSync(tempPath);
104
+ }
105
+ catch {
106
+ // best effort cleanup
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+ export function applyStatusLineInstall(scope, projectRoot, options = {}) {
112
+ const root = resolveSettingsRoot(scope, projectRoot);
113
+ const settingsPath = resolveSettingsPath(scope, projectRoot);
114
+ assertSafeSettingsPath(scope, root, settingsPath);
115
+ const exists = existsSync(settingsPath);
116
+ const settings = readSettings(settingsPath);
117
+ const plan = buildPlan(scope, settingsPath, settings, exists);
118
+ if (plan.alreadyInstalled) {
119
+ return { ...plan, applied: false };
120
+ }
121
+ if (plan.conflict && !options.force) {
122
+ return { ...plan, applied: false };
123
+ }
124
+ const entry = { type: 'command', command: STATUSLINE_COMMAND, padding: 0 };
125
+ const nextSettings = { ...settings, statusLine: entry };
126
+ atomicWriteJson(settingsPath, nextSettings);
127
+ return { ...plan, applied: true };
128
+ }
129
+ export function removeStatusLineInstall(scope, projectRoot) {
130
+ const root = resolveSettingsRoot(scope, projectRoot);
131
+ const settingsPath = resolveSettingsPath(scope, projectRoot);
132
+ assertSafeSettingsPath(scope, root, settingsPath);
133
+ if (!existsSync(settingsPath)) {
134
+ return { scope, settingsPath, removed: false };
135
+ }
136
+ const settings = readSettings(settingsPath);
137
+ const existingCommand = extractExistingCommand(settings);
138
+ if (existingCommand === null || !existingCommand.includes(STATUSLINE_COMMAND)) {
139
+ return { scope, settingsPath, removed: false };
140
+ }
141
+ const { statusLine: _removed, ...rest } = settings;
142
+ atomicWriteJson(settingsPath, rest);
143
+ return { scope, settingsPath, removed: true };
144
+ }
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.1.0";
1
+ export declare const CLI_VERSION = "1.1.1";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.1.0";
1
+ export const CLI_VERSION = "1.1.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Peaks CLI and short skill family for Claude Code automation.",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -13,8 +13,8 @@
13
13
  "properties": {
14
14
  "id": {
15
15
  "type": "string",
16
- "pattern": "^(skill|skill-name|skill-parse|skill-runbook|skill-apply-note|skill-presence|schema|config|doctor-self|capability):[A-Za-z0-9][A-Za-z0-9._-]*$",
17
- "description": "Stable check id. Known prefixes: skill:<name> (required skill present), skill-name:<dir> (directory matches declared name), skill-parse:<dir> (skill metadata parsed), skill-runbook:<name> (Default runbook section exists), skill-apply-note:<name> (destructive --apply lines carry an authorization/--dry-run note), skill-presence:<topic> (status of .peaks/.active-skill.json — current/freshness), schema:<file> (schema file exists and is valid JSON), config:<scope> (optional config locations), doctor-self:<topic> (doctor validates its own output against this schema), capability:<name> (third-party capability is resolvable at the pinned version)."
16
+ "pattern": "^(skill|skill-name|skill-parse|skill-runbook|skill-apply-note|skill-presence|statusline|schema|config|doctor-self|capability):[A-Za-z0-9][A-Za-z0-9._-]*$",
17
+ "description": "Stable check id. Known prefixes: skill:<name> (required skill present), skill-name:<dir> (directory matches declared name), skill-parse:<dir> (skill metadata parsed), skill-runbook:<name> (Default runbook section exists), skill-apply-note:<name> (destructive --apply lines carry an authorization/--dry-run note), skill-presence:<topic> (status of .peaks/.active-skill.json — current/freshness), statusline:<topic> (whether the out-of-band Claude Code statusLine is installed), schema:<file> (schema file exists and is valid JSON), config:<scope> (optional config locations), doctor-self:<topic> (doctor validates its own output against this schema), capability:<name> (third-party capability is resolvable at the pinned version)."
18
18
  },
19
19
  "ok": { "type": "boolean" },
20
20
  "message": { "type": "string", "minLength": 1 }
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-prd --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-qa --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-rd --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-sc --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -74,6 +74,12 @@ Only after the mode is known (user selected or explicitly named), run:
74
74
  peaks skill presence:set peaks-solo --project <repo> --mode <mode-value> --gate startup
75
75
  ```
76
76
 
77
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
78
+
79
+ ```bash
80
+ peaks statusline install --project <repo> # idempotent; skips if already installed
81
+ ```
82
+
77
83
  Then display the compact status header: `Peaks-Cli Skill: peaks-solo | Peaks-Cli Gate: startup | Next: <one short action>`. Display this header on EVERY turn while the skill is active.
78
84
 
79
85
  Update with `peaks skill presence:set peaks-solo --project <repo> --mode <mode> --gate <gate>` when gates change. The presence file persists across the full workflow lifecycle — do NOT clear it at workflow end.
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-txt --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash
@@ -14,6 +14,13 @@ Before any analysis or tool call, immediately run:
14
14
  ```bash
15
15
  peaks skill presence:set peaks-ui --project <repo> --mode <mode> --gate startup
16
16
  ```
17
+
18
+ On the first presence:set in a project, ensure the out-of-band status bar is installed so the user can see at a glance that Peaks is orchestrating — it renders the active skill in Claude Code's terminal status line, independent of model output:
19
+
20
+ ```bash
21
+ peaks statusline install --project <repo> # idempotent; skips if already installed
22
+ ```
23
+
17
24
  Read persistent project memory via CLI (durable, LLM-authored memories):
18
25
 
19
26
  ```bash