claude-dev-env 8.85.1 → 8.85.3
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/.agents/skills/efficient-review/SKILL.md +79 -0
- package/.agents/skills/efficient-review/defaults.json +12 -0
- package/.agents/skills/efficient-review/reference/cloud-session.md +13 -0
- package/bin/codex-trimmed-sol.test.mjs +1 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-deepstack-plugin.mjs +300 -0
- package/bin/install-deepstack-plugin.test.mjs +98 -0
- package/bin/install-pstack-plugin.mjs +3 -5
- package/bin/install-pstack-plugin.test.mjs +28 -0
- package/bin/install.agents-home.test.mjs +1 -0
- package/bin/install.codex-rules.test.mjs +2 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +24 -0
- package/bin/install.profile-settings.test.mjs +1 -0
- package/bin/install.prune.test.mjs +20 -0
- package/bin/install.question-presentation.test.mjs +4 -0
- package/bin/install.settings-defaults.test.mjs +1 -0
- package/bin/install.shared-settings.test.mjs +1 -0
- package/bin/install.test.mjs +51 -1
- package/bin/install.transaction.test.mjs +8 -0
- package/bin/install.uninstall-transaction.test.mjs +2 -0
- package/bin/install.update-keeps-hooks.test.mjs +1 -0
- package/docs/CODE_RULES.md +4 -0
- package/docs/decisions/deepstack-install.md +9 -0
- package/docs/rule-guides/deepstack-wording.md +23 -0
- package/package.json +1 -1
- package/rules/deepstack-wording.md +15 -0
- package/scripts/deepstack_inventory.py +348 -0
- package/scripts/dev_env_scripts_constants/deepstack_inventory_constants.py +42 -0
- package/scripts/test_deepstack_inventory.py +137 -0
- package/scripts/test_deepstack_review_criterion.py +15 -0
- package/scripts/test_deepstack_wording_rule.py +20 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: efficient-review
|
|
3
|
+
description: >-
|
|
4
|
+
Run one low-cost code review of the current branch, or of a branch in another
|
|
5
|
+
repository, as a headless Claude run on haiku with the slim review profile.
|
|
6
|
+
An auto run lets the account broker pick the account. A headless run names
|
|
7
|
+
the account. Triggers: /efficient-review, "efficient review", "cheap review",
|
|
8
|
+
"haiku review of this branch".
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Efficient review
|
|
12
|
+
|
|
13
|
+
This skill runs one headless code review of a branch on haiku and prints its findings. Pick the mode, run the planner, then run the review on the machine that holds the Claude accounts, or through the agent-pc runner from a cloud session.
|
|
14
|
+
|
|
15
|
+
Argument shape: `/efficient-review [auto|headless] [account] [model=] [effort=] [level=] [base=]`. Pass `model=` only as the Defaults section allows.
|
|
16
|
+
A blank argument comes from [`defaults.json`](defaults.json).
|
|
17
|
+
|
|
18
|
+
## Defaults
|
|
19
|
+
|
|
20
|
+
**Always run the review on haiku.** `defaults.json` names `haiku`, and a blank `model` input runs it. Pass `model=` or the workflow's `model` input only when the owner's own message names the family as a directive, such as "run this review on sonnet". Then run it on that family. Do not ask which model to use. Do not change the model on your own, for depth, for cost, or because passes keep finding issues. A stream of findings is a reason to fix the findings and run haiku again.
|
|
21
|
+
|
|
22
|
+
**The review must run headless.** Never spawn the review as an Agent subagent in this session. A subagent runs inside the parent session, so it cannot load the slim profile's setting sources, plugin folder, MCP config, or auto-compact window. `defaults.json` holds a headless mode, and `load_defaults` refuses any other mode.
|
|
23
|
+
|
|
24
|
+
## Subcommands
|
|
25
|
+
|
|
26
|
+
Run each one as `python -m tools.efficient_review <subcommand> <arguments>` from the checkout that contains `tools/efficient_review.py`.
|
|
27
|
+
|
|
28
|
+
| Subcommand | Action |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `plan` | Prints the mode, account, model family, harness, effort, level, base, and the prompt. |
|
|
31
|
+
| `accounts` | Lists the Claude accounts a headless run can name. |
|
|
32
|
+
| `run` | Runs a headless or auto review and prints the review, the served models, and the saved file. |
|
|
33
|
+
|
|
34
|
+
## Modes
|
|
35
|
+
|
|
36
|
+
| Mode | Picked when | Account |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| auto | No mode word and no account. The word `auto` picks it too. | The account broker picks it. Ask nothing. |
|
|
39
|
+
| headless | The word `headless`, or an account launcher name. | Required. With no account named, ask for one. |
|
|
40
|
+
|
|
41
|
+
A model is a family name: haiku, sonnet, opus, sol, or luna. The planner refuses a versioned model ID. `codex review` takes no level, so the planner refuses `level=` with sol or luna.
|
|
42
|
+
|
|
43
|
+
## 1. Headless mode with no account
|
|
44
|
+
|
|
45
|
+
Run `python -m tools.efficient_review accounts`. Ask with the session's question tool, one option per listed account, and wait for the answer. The answer is the account. On the machine that holds the launchers, the list comes from the `claude-*.cmd` launchers, without `claude-base`, `claude-raw`, and `claude-kimi`. In the cloud the list is the known roster.
|
|
46
|
+
|
|
47
|
+
## 2. Headless or auto on that machine
|
|
48
|
+
|
|
49
|
+
Run `python -m tools.efficient_review run <arguments> --repository <checkout>`. When the checkout has no launcher folder, go to step 3.
|
|
50
|
+
|
|
51
|
+
- It builds the slim profile from `tools.review_profile` into a temp folder.
|
|
52
|
+
- Auto asks `account_broker.py choose --product claude` for the account.
|
|
53
|
+
- It runs the account launcher with `-p`, the model alias, the effort, the profile arguments, and the environment. The system prompt file holds the review rules from the checkout that contains the runner, and the code standards. The prompt and the diff against the base go on standard input.
|
|
54
|
+
- A sol or luna family runs `codex review` on the newest slug of that family. The log prints that slug.
|
|
55
|
+
- It prints the review, the served models from the stream's `modelUsage` keys, and the saved file. The review and its record go to a temp folder outside the repository.
|
|
56
|
+
|
|
57
|
+
## 3. Headless or auto from a cloud session
|
|
58
|
+
|
|
59
|
+
The cloud container has no other Claude accounts, so the review runs on the agent-pc runner. Read [reference/cloud-session.md](reference/cloud-session.md).
|
|
60
|
+
|
|
61
|
+
## Reading guide
|
|
62
|
+
|
|
63
|
+
| File | What it does |
|
|
64
|
+
|---|---|
|
|
65
|
+
| [`reference/cloud-session.md`](reference/cloud-session.md) | Dispatching the review from a cloud session and reading its results branch. |
|
|
66
|
+
| [`defaults.json`](defaults.json) | The default model family, effort, level, prompt, base, profile, advisor, validator, mode, and timeout. |
|
|
67
|
+
|
|
68
|
+
## Common pitfalls
|
|
69
|
+
|
|
70
|
+
- **Picking a stronger model after several passes.** Fix the findings and run haiku again.
|
|
71
|
+
- **Setting a valid finding aside.** Check each finding against the code. Fix every one that holds in the same branch, even when it reaches files the diff did not touch. Answer only the findings that fail the check, with the file and line that rules each out.
|
|
72
|
+
- **Asking which model to use.** The answer is haiku. When the owner names another family, run it without a confirmation question.
|
|
73
|
+
- **Spawning the review as a subagent.** The review loses the slim profile. Run it headless.
|
|
74
|
+
- **Dispatching from the branch under review.** The workflow runs only from `main`.
|
|
75
|
+
|
|
76
|
+
## Out of scope
|
|
77
|
+
|
|
78
|
+
- The `validator` default stays `off`.
|
|
79
|
+
- A fixer step after the review. The skill has no fixer step.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Headless or auto from a cloud session
|
|
2
|
+
|
|
3
|
+
The cloud container has no other Claude accounts, so the review runs on the agent-pc runner.
|
|
4
|
+
|
|
5
|
+
1. From the checkout that contains `tools/efficient_review.py`, dispatch `.github/workflows/efficient-review.yml` on ref `main`. The job runs only from `main`, so the runner executes the reviewed tool code and never the branch under review. Inputs: `account` (blank means auto), `effort`, `level`, `base`, `ref` (the branch to review), and `repository` (the `owner/name` to review when it is not that checkout). Leave `model` blank.
|
|
6
|
+
2. Wait for the run to finish.
|
|
7
|
+
3. Read `review.md` from the results branch `efficient-review-results/<run id>-<attempt>`. The branch holds that file and `review.json`. The commit message names the account, the launcher, and the served models. Print the review and the served models.
|
|
8
|
+
|
|
9
|
+
A review of another repository checks it out with the read token configured for that workflow.
|
|
10
|
+
|
|
11
|
+
To review a pull request in the checkout that contains the workflow, pass its number as `pull_request` and leave `ref` blank. The run reviews the pull request head, then posts each finding on it as a review thread.
|
|
12
|
+
|
|
13
|
+
For a pull request in this package's public repository, pass `repository` as `jl-cmd/claude-dev-env` and `pull_request` together. The workflow accepts that repository name. The run posts nothing on the public pull request. When the review lists findings, it dispatches the landing repair with the pull request number and the results branch.
|
|
@@ -32,6 +32,7 @@ function installInScratchHome(homeDirectory, argumentsList = [], environment = {
|
|
|
32
32
|
CLAUDE_CONFIG_DIR: undefined,
|
|
33
33
|
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
34
34
|
CDE_INSTALL_PSTACK: '0',
|
|
35
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
35
36
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
36
37
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
37
38
|
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
installMarketplacePlugins,
|
|
7
|
+
marketplacePluginsPlan,
|
|
8
|
+
shouldInstallMarketplacePlugin,
|
|
9
|
+
} from './install-pstack-plugin.mjs';
|
|
10
|
+
|
|
11
|
+
export const DEEPSTACK_MARKETPLACE_REPOSITORY = 'jl-cmd-projects/DeepStack';
|
|
12
|
+
export const DEEPSTACK_MARKETPLACE_NAME = 'DeepStack';
|
|
13
|
+
export const DEEPSTACK_PLUGIN_NAME = 'deepstack';
|
|
14
|
+
export const DEEPSTACK_PLUGIN_IDENTIFIER = `${DEEPSTACK_PLUGIN_NAME}@${DEEPSTACK_MARKETPLACE_NAME}`;
|
|
15
|
+
export const DEEPSTACK_PLUGIN_HOSTS = Object.freeze(['claude', 'codex']);
|
|
16
|
+
export const DEEPSTACK_PLUGIN_OPT_OUT_FLAG = '--no-deepstack';
|
|
17
|
+
export const DEEPSTACK_PLUGIN_OPT_OUT_VARIABLE = 'CDE_INSTALL_DEEPSTACK';
|
|
18
|
+
export const DEEPSTACK_SOURCE_VARIABLE = 'CDE_DEEPSTACK_SOURCE';
|
|
19
|
+
export const DEEPSTACK_CURSOR_DIRECTORY_NAME = 'plugins/deepstack';
|
|
20
|
+
export const DEEPSTACK_SKILL_RELATIVE_PATH = 'skills/deepstack-mode/SKILL.md';
|
|
21
|
+
export const DEEPSTACK_SESSION_SCRIPT_RELATIVE_PATH = 'hooks/session-start.sh';
|
|
22
|
+
export const DEEPSTACK_SESSION_RUNTIME = 'cursor';
|
|
23
|
+
export const DEEPSTACK_FIRST_TASK_HOOK_NAME = 'deepen_first_prompt.py';
|
|
24
|
+
export const DEEPSTACK_CODEX_MODEL_SHEET_NAME = 'deepstack-models.md';
|
|
25
|
+
|
|
26
|
+
export const DEEPSTACK_PLUGIN_SPEC = Object.freeze({
|
|
27
|
+
name: DEEPSTACK_PLUGIN_NAME,
|
|
28
|
+
label: 'DeepStack',
|
|
29
|
+
marketplaceRepository: DEEPSTACK_MARKETPLACE_REPOSITORY,
|
|
30
|
+
marketplaceName: DEEPSTACK_MARKETPLACE_NAME,
|
|
31
|
+
sparsePaths: Object.freeze([]),
|
|
32
|
+
pluginIdentifier: DEEPSTACK_PLUGIN_IDENTIFIER,
|
|
33
|
+
hosts: DEEPSTACK_PLUGIN_HOSTS,
|
|
34
|
+
optOutFlag: DEEPSTACK_PLUGIN_OPT_OUT_FLAG,
|
|
35
|
+
optOutVariable: DEEPSTACK_PLUGIN_OPT_OUT_VARIABLE,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Quote one path for a hook command line.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} pathValue The absolute path.
|
|
42
|
+
* @returns {string} A single-quoted shell word.
|
|
43
|
+
*/
|
|
44
|
+
function quoteCommandPath(pathValue) {
|
|
45
|
+
return `'${pathValue.replaceAll("'", `'\\''`)}'`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the Claude, Codex, and Cursor actions one DeepStack install performs.
|
|
50
|
+
*
|
|
51
|
+
* Claude and Codex use the marketplace verbs. Cursor has no plugin command, so
|
|
52
|
+
* its action is a copy into the Cursor home plus two hook registrations.
|
|
53
|
+
*
|
|
54
|
+
* @returns {{claude: object, codex: object, cursor: {
|
|
55
|
+
* pluginDirectoryName: string,
|
|
56
|
+
* skillRelativePath: string,
|
|
57
|
+
* sessionScriptRelativePath: string,
|
|
58
|
+
* sessionRuntimeArgument: string,
|
|
59
|
+
* firstTaskHookName: string,
|
|
60
|
+
* }}} The three host actions.
|
|
61
|
+
*/
|
|
62
|
+
export function deepStackInstallPlan() {
|
|
63
|
+
return {
|
|
64
|
+
claude: marketplacePluginsPlan([DEEPSTACK_PLUGIN_SPEC], 'claude'),
|
|
65
|
+
codex: marketplacePluginsPlan([DEEPSTACK_PLUGIN_SPEC], 'codex'),
|
|
66
|
+
cursor: {
|
|
67
|
+
pluginDirectoryName: DEEPSTACK_CURSOR_DIRECTORY_NAME,
|
|
68
|
+
skillRelativePath: DEEPSTACK_SKILL_RELATIVE_PATH,
|
|
69
|
+
sessionScriptRelativePath: DEEPSTACK_SESSION_SCRIPT_RELATIVE_PATH,
|
|
70
|
+
sessionRuntimeArgument: DEEPSTACK_SESSION_RUNTIME,
|
|
71
|
+
firstTaskHookName: DEEPSTACK_FIRST_TASK_HOOK_NAME,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string|undefined} homeDirectory A Claude or Codex home.
|
|
78
|
+
* @returns {string|null} The newest cached plugin directory, or null.
|
|
79
|
+
*/
|
|
80
|
+
function newestCachedPlugin(homeDirectory) {
|
|
81
|
+
if (!homeDirectory) return null;
|
|
82
|
+
const cacheDirectory = join(
|
|
83
|
+
homeDirectory,
|
|
84
|
+
'plugins',
|
|
85
|
+
'cache',
|
|
86
|
+
DEEPSTACK_MARKETPLACE_NAME,
|
|
87
|
+
DEEPSTACK_PLUGIN_NAME,
|
|
88
|
+
);
|
|
89
|
+
if (!existsSync(cacheDirectory)) return null;
|
|
90
|
+
const versions = readdirSync(cacheDirectory, { withFileTypes: true })
|
|
91
|
+
.filter(entry => entry.isDirectory())
|
|
92
|
+
.map(entry => entry.name)
|
|
93
|
+
.filter(name => existsSync(join(cacheDirectory, name, DEEPSTACK_SKILL_RELATIVE_PATH)))
|
|
94
|
+
.sort();
|
|
95
|
+
if (versions.length === 0) return null;
|
|
96
|
+
return join(cacheDirectory, versions[versions.length - 1]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Find the plugin tree to copy into Cursor.
|
|
101
|
+
*
|
|
102
|
+
* An explicit directory wins, then the source variable, then a marketplace
|
|
103
|
+
* cache written by the Claude or Codex install, then a shallow clone of
|
|
104
|
+
* jl-cmd-projects/DeepStack.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} options Search inputs.
|
|
107
|
+
* @param {string} [options.claudeRoot] The Claude home.
|
|
108
|
+
* @param {string} [options.codexHome] The Codex home.
|
|
109
|
+
* @param {Record<string, string|undefined>} [options.environment] The process environment.
|
|
110
|
+
* @param {string} [options.sourceDirectory] A directory that already holds the plugin.
|
|
111
|
+
* @param {(destination: string) => string|null} [options.cloneRepository] Replaces the git clone.
|
|
112
|
+
* @returns {string|null} The plugin directory, or null when every source is empty.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveDeepStackPluginSource(options = {}) {
|
|
115
|
+
if (options.sourceDirectory && existsSync(join(options.sourceDirectory, DEEPSTACK_SKILL_RELATIVE_PATH))) {
|
|
116
|
+
return options.sourceDirectory;
|
|
117
|
+
}
|
|
118
|
+
const environment = options.environment ?? process.env;
|
|
119
|
+
const fromEnvironment = environment[DEEPSTACK_SOURCE_VARIABLE];
|
|
120
|
+
if (fromEnvironment && existsSync(join(fromEnvironment, DEEPSTACK_SKILL_RELATIVE_PATH))) {
|
|
121
|
+
return fromEnvironment;
|
|
122
|
+
}
|
|
123
|
+
const cached = newestCachedPlugin(options.claudeRoot) ?? newestCachedPlugin(options.codexHome);
|
|
124
|
+
if (cached) return cached;
|
|
125
|
+
if (options.cloneRepository) return options.cloneRepository();
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Add the SessionStart and first-task hook groups when they are absent.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} configuration The parsed Cursor hooks file.
|
|
133
|
+
* @param {{sessionCommand: string, firstTaskCommand: string}} commands The two commands.
|
|
134
|
+
* @returns {object} The configuration with both groups present.
|
|
135
|
+
*/
|
|
136
|
+
export function mergeDeepStackCursorHooks(configuration, commands) {
|
|
137
|
+
const hooks = configuration.hooks && typeof configuration.hooks === 'object'
|
|
138
|
+
? configuration.hooks
|
|
139
|
+
: {};
|
|
140
|
+
const sessionGroups = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
|
|
141
|
+
const promptGroups = Array.isArray(hooks.UserPromptSubmit) ? [...hooks.UserPromptSubmit] : [];
|
|
142
|
+
const listed = (groups, needle) => JSON.stringify(groups).includes(needle);
|
|
143
|
+
if (!listed(sessionGroups, DEEPSTACK_SESSION_SCRIPT_RELATIVE_PATH)) {
|
|
144
|
+
sessionGroups.push({
|
|
145
|
+
matcher: 'startup|resume|clear|compact',
|
|
146
|
+
hooks: [{ type: 'command', command: commands.sessionCommand }],
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (!listed(promptGroups, DEEPSTACK_FIRST_TASK_HOOK_NAME)) {
|
|
150
|
+
promptGroups.push({
|
|
151
|
+
matcher: '',
|
|
152
|
+
hooks: [{ type: 'command', command: commands.firstTaskCommand, timeout: 10 }],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
...configuration,
|
|
157
|
+
hooks: {
|
|
158
|
+
...hooks,
|
|
159
|
+
SessionStart: sessionGroups,
|
|
160
|
+
UserPromptSubmit: promptGroups,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Copy the plugin into the Cursor home and register both hooks.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} options Copy inputs.
|
|
169
|
+
* @param {string} options.cursorDirectory The Cursor home, usually ~/.cursor.
|
|
170
|
+
* @param {string|null} options.pluginSourceDirectory The plugin tree to copy.
|
|
171
|
+
* @param {string} options.firstTaskHookPath Absolute path of deepen_first_prompt.py.
|
|
172
|
+
* @returns {{status: string, pluginDirectory: string|null, hooksPath: string|null, warning: string|null}} The outcome.
|
|
173
|
+
*/
|
|
174
|
+
export function installDeepStackCursor(options) {
|
|
175
|
+
const plan = deepStackInstallPlan().cursor;
|
|
176
|
+
if (!options.cursorDirectory) {
|
|
177
|
+
return { status: 'failed', pluginDirectory: null, hooksPath: null, warning: 'Cursor home is unset.' };
|
|
178
|
+
}
|
|
179
|
+
if (!options.pluginSourceDirectory) {
|
|
180
|
+
return {
|
|
181
|
+
status: 'failed',
|
|
182
|
+
pluginDirectory: null,
|
|
183
|
+
hooksPath: null,
|
|
184
|
+
warning: `No ${DEEPSTACK_MARKETPLACE_REPOSITORY} plugin tree to copy.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const pluginDirectory = join(options.cursorDirectory, plan.pluginDirectoryName);
|
|
188
|
+
mkdirSync(options.cursorDirectory, { recursive: true });
|
|
189
|
+
cpSync(options.pluginSourceDirectory, pluginDirectory, { recursive: true, force: true });
|
|
190
|
+
const sessionCommand = `${quoteCommandPath(join(pluginDirectory, plan.sessionScriptRelativePath))} ${plan.sessionRuntimeArgument}`;
|
|
191
|
+
const firstTaskCommand = `python3 ${quoteCommandPath(options.firstTaskHookPath)}`;
|
|
192
|
+
const hooksPath = join(options.cursorDirectory, 'hooks.json');
|
|
193
|
+
const current = existsSync(hooksPath)
|
|
194
|
+
? JSON.parse(readFileSync(hooksPath, 'utf8'))
|
|
195
|
+
: { hooks: {} };
|
|
196
|
+
const merged = mergeDeepStackCursorHooks(current, { sessionCommand, firstTaskCommand });
|
|
197
|
+
writeFileSync(hooksPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
198
|
+
return { status: 'installed', pluginDirectory, hooksPath, warning: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Write the Codex model sheet when this run installed the Codex plugin.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} codexHome The Codex home.
|
|
205
|
+
* @returns {string|null} The sheet path when this run created it.
|
|
206
|
+
*/
|
|
207
|
+
export function seedCodexDeepStackModels(codexHome) {
|
|
208
|
+
const sheetPath = join(codexHome, DEEPSTACK_CODEX_MODEL_SHEET_NAME);
|
|
209
|
+
if (existsSync(sheetPath)) return null;
|
|
210
|
+
mkdirSync(codexHome, { recursive: true });
|
|
211
|
+
writeFileSync(sheetPath, 'session hook: on\n');
|
|
212
|
+
return sheetPath;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Install DeepStack for Claude, Codex, and Cursor.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} [options] Install targets.
|
|
219
|
+
* @param {string} [options.claudeRoot] The managed Claude root.
|
|
220
|
+
* @param {string} [options.codexHome] The Codex home.
|
|
221
|
+
* @param {string} [options.cursorDirectory] The Cursor home.
|
|
222
|
+
* @param {string} [options.firstTaskHookPath] Absolute path of the hook the first-task command runs.
|
|
223
|
+
* @param {string} [options.sourceDirectory] A local plugin tree for the Cursor copy.
|
|
224
|
+
* @param {string[]} [options.argumentList] Command-line arguments, for the opt-out flag.
|
|
225
|
+
* @param {Record<string, string|undefined>} [options.environment] The process environment.
|
|
226
|
+
* @param {object} [dependencies] Seams for the marketplace command runner.
|
|
227
|
+
* @returns {{status: string, hosts: object[], cursor: object, warning: string|null}} The outcome.
|
|
228
|
+
*/
|
|
229
|
+
export function installDeepStackForHosts(options = {}, dependencies = {}) {
|
|
230
|
+
const environment = options.environment ?? process.env;
|
|
231
|
+
if (!shouldInstallMarketplacePlugin(DEEPSTACK_PLUGIN_SPEC, options.argumentList, environment)) {
|
|
232
|
+
return {
|
|
233
|
+
status: 'skipped',
|
|
234
|
+
hosts: [],
|
|
235
|
+
cursor: { status: 'skipped', pluginDirectory: null, hooksPath: null, warning: null },
|
|
236
|
+
warning: null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const marketplace = installMarketplacePlugins([DEEPSTACK_PLUGIN_SPEC], {
|
|
240
|
+
claudeRoot: options.claudeRoot,
|
|
241
|
+
codexHome: options.codexHome,
|
|
242
|
+
environment,
|
|
243
|
+
}, dependencies);
|
|
244
|
+
let sourceDirectory = resolveDeepStackPluginSource({
|
|
245
|
+
claudeRoot: options.claudeRoot,
|
|
246
|
+
codexHome: options.codexHome,
|
|
247
|
+
environment,
|
|
248
|
+
sourceDirectory: options.sourceDirectory,
|
|
249
|
+
});
|
|
250
|
+
if (!sourceDirectory && options.allowClone !== false) {
|
|
251
|
+
sourceDirectory = cloneDeepStackRepository(mkdtempSync(join(tmpdir(), 'deepstack-')));
|
|
252
|
+
}
|
|
253
|
+
const cursor = installDeepStackCursor({
|
|
254
|
+
cursorDirectory: options.cursorDirectory,
|
|
255
|
+
pluginSourceDirectory: sourceDirectory,
|
|
256
|
+
firstTaskHookPath: options.firstTaskHookPath,
|
|
257
|
+
});
|
|
258
|
+
if (marketplace.hosts.some(host => host.host === 'codex' && host.status === 'installed') && options.codexHome) {
|
|
259
|
+
seedCodexDeepStackModels(options.codexHome);
|
|
260
|
+
}
|
|
261
|
+
const failed = marketplace.status === 'failed' || cursor.status === 'failed';
|
|
262
|
+
const installed = marketplace.hosts.some(host => host.status === 'installed') || cursor.status === 'installed';
|
|
263
|
+
return {
|
|
264
|
+
status: failed ? 'failed' : (installed ? 'installed' : marketplace.status),
|
|
265
|
+
hosts: marketplace.hosts,
|
|
266
|
+
cursor,
|
|
267
|
+
warning: [marketplace.warning, cursor.warning].filter(Boolean).join(' ') || null,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Clone jl-cmd-projects/DeepStack when no local tree is available.
|
|
273
|
+
*
|
|
274
|
+
* The installer calls this only from a full install that has no source
|
|
275
|
+
* directory and no marketplace cache. Tests pass a source directory instead.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} destination An empty directory for the clone.
|
|
278
|
+
* @returns {string|null} The plugin directory inside the clone.
|
|
279
|
+
*/
|
|
280
|
+
/**
|
|
281
|
+
* Return the git clone URL for the DeepStack marketplace repository.
|
|
282
|
+
*
|
|
283
|
+
* @returns {string} The clone URL.
|
|
284
|
+
*/
|
|
285
|
+
export function deepStackCloneUrl() {
|
|
286
|
+
return ['https:', '', 'github.com', `${DEEPSTACK_MARKETPLACE_REPOSITORY}.git`].join('/');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function cloneDeepStackRepository(destination) {
|
|
290
|
+
const outcome = spawnSync('git', [
|
|
291
|
+
'clone',
|
|
292
|
+
'--depth',
|
|
293
|
+
'1',
|
|
294
|
+
deepStackCloneUrl(),
|
|
295
|
+
destination,
|
|
296
|
+
], { encoding: 'utf8' });
|
|
297
|
+
if (outcome.status !== 0) return null;
|
|
298
|
+
const pluginDirectory = join(destination, 'plugins', DEEPSTACK_PLUGIN_NAME);
|
|
299
|
+
return existsSync(join(pluginDirectory, DEEPSTACK_SKILL_RELATIVE_PATH)) ? pluginDirectory : null;
|
|
300
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
DEEPSTACK_MARKETPLACE_REPOSITORY,
|
|
8
|
+
DEEPSTACK_PLUGIN_IDENTIFIER,
|
|
9
|
+
DEEPSTACK_PLUGIN_SPEC,
|
|
10
|
+
deepStackCloneUrl,
|
|
11
|
+
deepStackInstallPlan,
|
|
12
|
+
installDeepStackCursor,
|
|
13
|
+
installDeepStackForHosts,
|
|
14
|
+
mergeDeepStackCursorHooks,
|
|
15
|
+
} from './install-deepstack-plugin.mjs';
|
|
16
|
+
import { marketplacePluginsPlan, shouldInstallMarketplacePlugin } from './install-pstack-plugin.mjs';
|
|
17
|
+
|
|
18
|
+
test('Claude and Codex plans name jl-cmd-projects/DeepStack', () => {
|
|
19
|
+
const plan = deepStackInstallPlan();
|
|
20
|
+
assert.deepEqual(plan.claude.commands, [
|
|
21
|
+
['plugin', 'marketplace', 'add', DEEPSTACK_MARKETPLACE_REPOSITORY],
|
|
22
|
+
['plugin', 'install', DEEPSTACK_PLUGIN_IDENTIFIER],
|
|
23
|
+
]);
|
|
24
|
+
assert.deepEqual(plan.codex.commands, [
|
|
25
|
+
['plugin', 'marketplace', 'add', DEEPSTACK_MARKETPLACE_REPOSITORY],
|
|
26
|
+
['plugin', 'add', DEEPSTACK_PLUGIN_IDENTIFIER],
|
|
27
|
+
]);
|
|
28
|
+
assert.equal(DEEPSTACK_MARKETPLACE_REPOSITORY, 'jl-cmd-projects/DeepStack');
|
|
29
|
+
assert.equal(deepStackCloneUrl(), 'https://github.com/jl-cmd-projects/DeepStack.git');
|
|
30
|
+
assert.equal(plan.cursor.skillRelativePath, 'skills/deepstack-mode/SKILL.md');
|
|
31
|
+
assert.equal(plan.cursor.firstTaskHookName, 'deepen_first_prompt.py');
|
|
32
|
+
assert.throws(() => marketplacePluginsPlan([DEEPSTACK_PLUGIN_SPEC], 'cursor'), /cursor/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('cursor hooks keep the session hook and the first-task gate', () => {
|
|
36
|
+
const merged = mergeDeepStackCursorHooks({ hooks: { PreToolUse: [] } }, {
|
|
37
|
+
sessionCommand: "'/cursor/plugins/deepstack/hooks/session-start.sh' cursor",
|
|
38
|
+
firstTaskCommand: "python3 '/claude/hooks/session/deepen_first_prompt.py'",
|
|
39
|
+
});
|
|
40
|
+
assert.deepEqual(Object.keys(merged.hooks).sort(), ['PreToolUse', 'SessionStart', 'UserPromptSubmit']);
|
|
41
|
+
const text = JSON.stringify(merged);
|
|
42
|
+
assert.match(text, /session-start\.sh/);
|
|
43
|
+
assert.match(text, /deepen_first_prompt\.py/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the cursor copy writes the skill and both hook commands', () => {
|
|
47
|
+
const root = mkdtempSync(join(tmpdir(), 'deepstack-cursor-'));
|
|
48
|
+
try {
|
|
49
|
+
const source = join(root, 'source');
|
|
50
|
+
mkdirSync(join(source, 'skills', 'deepstack-mode'), { recursive: true });
|
|
51
|
+
mkdirSync(join(source, 'hooks'), { recursive: true });
|
|
52
|
+
writeFileSync(join(source, 'skills', 'deepstack-mode', 'SKILL.md'), 'DeepStack intake\n');
|
|
53
|
+
writeFileSync(join(source, 'hooks', 'session-start.sh'), '#!/bin/sh\n');
|
|
54
|
+
const cursorDirectory = join(root, '.cursor');
|
|
55
|
+
const outcome = installDeepStackCursor({
|
|
56
|
+
cursorDirectory,
|
|
57
|
+
pluginSourceDirectory: source,
|
|
58
|
+
firstTaskHookPath: join(root, '.agents', 'hooks', 'session', 'deepen_first_prompt.py'),
|
|
59
|
+
});
|
|
60
|
+
assert.equal(outcome.status, 'installed');
|
|
61
|
+
assert.equal(
|
|
62
|
+
readFileSync(join(cursorDirectory, 'plugins', 'deepstack', 'skills', 'deepstack-mode', 'SKILL.md'), 'utf8'),
|
|
63
|
+
'DeepStack intake\n',
|
|
64
|
+
);
|
|
65
|
+
const hooks = JSON.parse(readFileSync(join(cursorDirectory, 'hooks.json'), 'utf8'));
|
|
66
|
+
const text = JSON.stringify(hooks);
|
|
67
|
+
assert.match(text, /session-start\.sh/);
|
|
68
|
+
assert.match(text, /deepen_first_prompt\.py/);
|
|
69
|
+
assert.equal(hooks.hooks.SessionStart[0].hooks[0].command.endsWith(' cursor'), true);
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(root, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a missing plugin tree fails the cursor copy', () => {
|
|
76
|
+
const outcome = installDeepStackCursor({
|
|
77
|
+
cursorDirectory: '/tmp/unused-cursor',
|
|
78
|
+
pluginSourceDirectory: null,
|
|
79
|
+
firstTaskHookPath: '/tmp/deepen_first_prompt.py',
|
|
80
|
+
});
|
|
81
|
+
assert.equal(outcome.status, 'failed');
|
|
82
|
+
assert.match(outcome.warning, /jl-cmd-projects\/DeepStack/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('--no-deepstack and CDE_INSTALL_DEEPSTACK=0 skip the install', () => {
|
|
86
|
+
assert.equal(shouldInstallMarketplacePlugin(DEEPSTACK_PLUGIN_SPEC, ['--no-deepstack'], {}), false);
|
|
87
|
+
assert.equal(
|
|
88
|
+
shouldInstallMarketplacePlugin(DEEPSTACK_PLUGIN_SPEC, [], { CDE_INSTALL_DEEPSTACK: '0' }),
|
|
89
|
+
false,
|
|
90
|
+
);
|
|
91
|
+
const skipped = installDeepStackForHosts({
|
|
92
|
+
argumentList: ['--no-deepstack'],
|
|
93
|
+
environment: {},
|
|
94
|
+
allowClone: false,
|
|
95
|
+
});
|
|
96
|
+
assert.equal(skipped.status, 'skipped');
|
|
97
|
+
assert.equal(skipped.cursor.status, 'skipped');
|
|
98
|
+
});
|
|
@@ -210,10 +210,8 @@ function namesAnAbsentCommand(outcome) {
|
|
|
210
210
|
&& COMMAND_NOT_FOUND_PATTERN.test(outcome.stderr ?? '');
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
function
|
|
214
|
-
|
|
215
|
-
if (!trimmed) return '';
|
|
216
|
-
return trimmed.split(/\r?\n/).filter(Boolean).at(-1);
|
|
213
|
+
function singleLineDetail(text) {
|
|
214
|
+
return (text ?? '').split(/\r?\n/).map(eachLine => eachLine.trim()).filter(Boolean).join(' ');
|
|
217
215
|
}
|
|
218
216
|
|
|
219
217
|
const ORPHANED_VERSION_MARKER = '.orphaned_at';
|
|
@@ -266,7 +264,7 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand)
|
|
|
266
264
|
});
|
|
267
265
|
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
|
|
268
266
|
if (outcome.status !== 0 || outcome.error) {
|
|
269
|
-
const detail =
|
|
267
|
+
const detail = singleLineDetail(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
|
|
270
268
|
return {
|
|
271
269
|
host,
|
|
272
270
|
executable,
|
|
@@ -126,6 +126,34 @@ test('a failing command reports that host and still installs the next one', () =
|
|
|
126
126
|
['plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY]);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test('should report every line of a multi-line marketplace add error', () => {
|
|
130
|
+
const cloneFailure = [
|
|
131
|
+
'Failed to add marketplace: Fetching the marketplace from GitHub failed on both attempts.'
|
|
132
|
+
+ ' HTTPS (https://github.com/jl-cmd/pstack-claude.git): HTTPS clone timed out',
|
|
133
|
+
'',
|
|
134
|
+
'SSH (git@github.com:jl-cmd/pstack-claude.git): git@github.com: Permission denied (publickey).',
|
|
135
|
+
'fatal: Could not read from remote repository.',
|
|
136
|
+
'',
|
|
137
|
+
'Please make sure you have the correct access rights',
|
|
138
|
+
'and the repository exists.',
|
|
139
|
+
].join('\r\n');
|
|
140
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
141
|
+
executable === 'claude' && commandArguments[2] === 'add'
|
|
142
|
+
? { status: 1, stderr: cloneFailure }
|
|
143
|
+
: { status: 0, stderr: '' }));
|
|
144
|
+
const claude = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, runner)
|
|
145
|
+
.hosts.find(host => host.host === 'claude');
|
|
146
|
+
assert.equal(
|
|
147
|
+
claude.warning,
|
|
148
|
+
`claude plugin marketplace add ${PSTACK_MARKETPLACE_REPOSITORY} failed: `
|
|
149
|
+
+ 'Failed to add marketplace: Fetching the marketplace from GitHub failed on both attempts.'
|
|
150
|
+
+ ' HTTPS (https://github.com/jl-cmd/pstack-claude.git): HTTPS clone timed out'
|
|
151
|
+
+ ' SSH (git@github.com:jl-cmd/pstack-claude.git): git@github.com: Permission denied (publickey).'
|
|
152
|
+
+ ' fatal: Could not read from remote repository.'
|
|
153
|
+
+ ' Please make sure you have the correct access rights and the repository exists.',
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
129
157
|
test('an executable override replaces the host command for that host only', () => {
|
|
130
158
|
const runner = recordingRunner();
|
|
131
159
|
installMarketplacePlugins(
|
|
@@ -53,6 +53,7 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
|
|
|
53
53
|
CLAUDE_CONFIG_DIR: undefined,
|
|
54
54
|
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
55
55
|
CDE_INSTALL_PSTACK: '0',
|
|
56
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
56
57
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
57
58
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
58
59
|
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|
|
@@ -59,6 +59,7 @@ function runInstaller(homeDirectory, extraArguments) {
|
|
|
59
59
|
CLAUDE_CONFIG_DIR: undefined,
|
|
60
60
|
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
61
61
|
CDE_INSTALL_PSTACK: '0',
|
|
62
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
62
63
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
63
64
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
64
65
|
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|
|
@@ -92,6 +93,7 @@ function scanTextWithProductionPiiScanner(scannedText) {
|
|
|
92
93
|
CLAUDE_CONFIG_DIR: undefined,
|
|
93
94
|
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
94
95
|
CDE_INSTALL_PSTACK: '0',
|
|
96
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
95
97
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
96
98
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
97
99
|
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|
|
@@ -33,6 +33,7 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
|
|
|
33
33
|
...process.env,
|
|
34
34
|
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
35
35
|
CDE_INSTALL_PSTACK: '0',
|
|
36
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
36
37
|
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
37
38
|
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
38
39
|
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|