@open-product-primer/cli 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/cli.js +2 -0
- package/dist/commands/context.d.ts +2 -0
- package/dist/commands/context.js +188 -0
- package/dist/commands/doctor.js +49 -0
- package/dist/lib/config-merge.js +5 -1
- package/dist/lib/install-agent.js +39 -0
- package/dist/lib/remote-context.d.ts +50 -0
- package/dist/lib/remote-context.js +299 -0
- package/dist/lib/templates.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,5 +27,11 @@ Bin aliases: `open-product-primer`, `oprim`.
|
|
|
27
27
|
| `oprim update` | Refresh `/oprim:*` assistant commands and skills |
|
|
28
28
|
| `oprim doctor` | Verify scaffold, integrations, and measurement env |
|
|
29
29
|
| `oprim measure` | Run KPI measurement pipeline for a bet |
|
|
30
|
+
| `oprim context` | Print resolved remote context content (`--source <name>` to scope to one) |
|
|
31
|
+
| `oprim context init` | Declare the current project a citable remote context (`--description`); prefer the guided `/oprim:context-init` skill in Claude Code |
|
|
32
|
+
| `oprim context register` | Register a remote context source (`--git <url>` or `--local <path>`, `--name`, optional `--description`) |
|
|
33
|
+
| `oprim context list` | List every registered source's name, kind, and description without fully resolving any of them |
|
|
34
|
+
|
|
35
|
+
A remote context is an oprim workspace bundled somewhere outside the current project's directory — a remote git repo or a local sibling directory — that this project can reference read-only. Content resolves fresh on demand (never a persistent clone you have to `git pull` yourself); `oprim context list` is the cheap way to see what's registered before pulling full content.
|
|
30
36
|
|
|
31
37
|
Full documentation: [github.com/eshraw/open-product-primer](https://github.com/eshraw/open-product-primer).
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ const doctor_1 = require("./commands/doctor");
|
|
|
11
11
|
const migrate_1 = require("./commands/migrate");
|
|
12
12
|
const measure_1 = require("./commands/measure");
|
|
13
13
|
const ovw_1 = require("./commands/ovw");
|
|
14
|
+
const context_1 = require("./commands/context");
|
|
14
15
|
const package_json_1 = __importDefault(require("../package.json"));
|
|
15
16
|
const program = new commander_1.Command();
|
|
16
17
|
program
|
|
@@ -23,4 +24,5 @@ program.addCommand((0, doctor_1.doctorCommand)());
|
|
|
23
24
|
program.addCommand((0, migrate_1.migrateCommand)());
|
|
24
25
|
program.addCommand((0, measure_1.measureCommand)());
|
|
25
26
|
program.addCommand((0, ovw_1.ovwCommand)());
|
|
27
|
+
program.addCommand((0, context_1.contextCommand)());
|
|
26
28
|
program.parse();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.contextCommand = contextCommand;
|
|
40
|
+
const commander_1 = require("commander");
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
43
|
+
const remote_context_1 = require("../lib/remote-context");
|
|
44
|
+
const scaffold_1 = require("../lib/scaffold");
|
|
45
|
+
function sourceKind(source) {
|
|
46
|
+
return (0, remote_context_1.isGitSource)(source) ? 'git' : 'path';
|
|
47
|
+
}
|
|
48
|
+
function initSubcommand() {
|
|
49
|
+
return new commander_1.Command('init')
|
|
50
|
+
.description('Declare the current project as a citable remote context')
|
|
51
|
+
.option('--description <text>', 'canonical description of what this context contains')
|
|
52
|
+
.action((opts) => {
|
|
53
|
+
const projectRoot = process.cwd();
|
|
54
|
+
if ((0, scaffold_1.fileExists)((0, remote_context_1.identityFilePath)(projectRoot))) {
|
|
55
|
+
console.log(chalk_1.default.yellow('A remote context identity already exists at ') + chalk_1.default.cyan('.oprim-context/context.yaml'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const name = path.basename(projectRoot);
|
|
59
|
+
(0, remote_context_1.writeIdentity)(projectRoot, { name, version: '1', description: opts.description });
|
|
60
|
+
console.log(chalk_1.default.green('✓') + ' .oprim-context/context.yaml created');
|
|
61
|
+
console.log(` name: ${name}`);
|
|
62
|
+
if (opts.description) {
|
|
63
|
+
console.log(` description: ${opts.description}`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
console.log(chalk_1.default.yellow(' no description set') +
|
|
67
|
+
' — other projects referencing this context via ' +
|
|
68
|
+
chalk_1.default.cyan('oprim context list') +
|
|
69
|
+
' will see it as description-less.');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function registerSubcommand() {
|
|
74
|
+
return new commander_1.Command('register')
|
|
75
|
+
.description('Register a remote context source in the current project')
|
|
76
|
+
.option('--git <url>', 'git remote URL of the remote context')
|
|
77
|
+
.option('--local <path>', 'local filesystem path of the remote context')
|
|
78
|
+
.requiredOption('--name <name>', 'name to register this source under')
|
|
79
|
+
.option('--description <text>', 'local note about why this source was registered')
|
|
80
|
+
.action((opts) => {
|
|
81
|
+
const projectRoot = process.cwd();
|
|
82
|
+
if ((opts.git && opts.local) || (!opts.git && !opts.local)) {
|
|
83
|
+
console.error(chalk_1.default.red('Exactly one of --git or --local is required.'));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const config = (0, remote_context_1.readRemoteContextConfig)(projectRoot);
|
|
87
|
+
if ((0, remote_context_1.findSourceByName)(config, opts.name)) {
|
|
88
|
+
console.error(chalk_1.default.red(`A source named "${opts.name}" is already registered.`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
const source = opts.git
|
|
92
|
+
? { name: opts.name, git: opts.git, description: opts.description }
|
|
93
|
+
: { name: opts.name, path: opts.local, description: opts.description };
|
|
94
|
+
config.enabled = true;
|
|
95
|
+
config.sources.push(source);
|
|
96
|
+
(0, remote_context_1.writeRemoteContextConfig)(projectRoot, config);
|
|
97
|
+
console.log(chalk_1.default.green('✓') + ` Registered "${opts.name}" (${sourceKind(source)})`);
|
|
98
|
+
const result = (0, remote_context_1.resolveIdentityOnly)(source);
|
|
99
|
+
if (result.error) {
|
|
100
|
+
console.log(chalk_1.default.yellow(' Could not confirm this source yet: ') +
|
|
101
|
+
result.error +
|
|
102
|
+
chalk_1.default.dim(' — try `oprim context list` or `oprim doctor` to retry later.'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (result.nameMismatch) {
|
|
106
|
+
console.log(chalk_1.default.yellow(` Warning: declared name "${result.nameMismatch.declared}" does not match resolved identity name "${result.nameMismatch.resolved}".`));
|
|
107
|
+
}
|
|
108
|
+
if (result.identity?.description) {
|
|
109
|
+
console.log(` ${chalk_1.default.dim('description:')} ${result.identity.description}`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
console.log(chalk_1.default.dim(' (no canonical description set on this source)'));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function listSubcommand() {
|
|
117
|
+
return new commander_1.Command('list')
|
|
118
|
+
.description('List every registered remote context source, without fully resolving any of them')
|
|
119
|
+
.action(() => {
|
|
120
|
+
const projectRoot = process.cwd();
|
|
121
|
+
const config = (0, remote_context_1.readRemoteContextConfig)(projectRoot);
|
|
122
|
+
if (!config.enabled || config.sources.length === 0) {
|
|
123
|
+
console.log('No remote contexts configured.');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (const source of config.sources) {
|
|
127
|
+
const kind = sourceKind(source);
|
|
128
|
+
console.log(`${chalk_1.default.bold(source.name)} ${chalk_1.default.dim(`(${kind})`)}`);
|
|
129
|
+
const result = (0, remote_context_1.resolveIdentityOnly)(source);
|
|
130
|
+
if (result.error) {
|
|
131
|
+
console.log(' ' + chalk_1.default.red(`unresolved: ${result.error}`));
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const desc = result.identity?.description;
|
|
135
|
+
console.log(' ' + (desc ? desc : chalk_1.default.dim('no description set')));
|
|
136
|
+
if (result.nameMismatch) {
|
|
137
|
+
console.log(chalk_1.default.yellow(` name mismatch: declared "${result.nameMismatch.declared}", resolved "${result.nameMismatch.resolved}"`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (source.description) {
|
|
141
|
+
console.log(' ' + chalk_1.default.dim(`local note: ${source.description}`));
|
|
142
|
+
}
|
|
143
|
+
console.log('');
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function contextCommand() {
|
|
148
|
+
const cmd = new commander_1.Command('context')
|
|
149
|
+
.description('Print resolved remote context content')
|
|
150
|
+
.option('--source <name>', 'limit output to a single named source')
|
|
151
|
+
.action((opts) => {
|
|
152
|
+
const projectRoot = process.cwd();
|
|
153
|
+
const config = (0, remote_context_1.readRemoteContextConfig)(projectRoot);
|
|
154
|
+
if (!config.enabled || config.sources.length === 0) {
|
|
155
|
+
console.log('No remote contexts configured.');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
let sources = config.sources;
|
|
159
|
+
if (opts.source) {
|
|
160
|
+
const match = (0, remote_context_1.findSourceByName)(config, opts.source);
|
|
161
|
+
if (!match) {
|
|
162
|
+
console.error(chalk_1.default.red(`No source named "${opts.source}" is registered.`));
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
sources = [match];
|
|
166
|
+
}
|
|
167
|
+
for (const source of sources) {
|
|
168
|
+
console.log(chalk_1.default.bold(`═══ ${source.name} ═══`));
|
|
169
|
+
const result = (0, remote_context_1.resolveFull)(source);
|
|
170
|
+
if (result.error) {
|
|
171
|
+
console.log(chalk_1.default.red(` unresolved: ${result.error}`));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (result.stale) {
|
|
175
|
+
console.log(chalk_1.default.yellow(' (stale — last successful fetch could not be refreshed)'));
|
|
176
|
+
}
|
|
177
|
+
if (result.nameMismatch) {
|
|
178
|
+
console.log(chalk_1.default.yellow(` name mismatch: declared "${result.nameMismatch.declared}", resolved "${result.nameMismatch.resolved}"`));
|
|
179
|
+
}
|
|
180
|
+
console.log((0, remote_context_1.assembleOprimWorkspaceContent)(result.workspaceRoot));
|
|
181
|
+
console.log('');
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
cmd.addCommand(initSubcommand());
|
|
185
|
+
cmd.addCommand(registerSubcommand());
|
|
186
|
+
cmd.addCommand(listSubcommand());
|
|
187
|
+
return cmd;
|
|
188
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -44,6 +44,7 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
44
44
|
const detect_1 = require("../lib/detect");
|
|
45
45
|
const measure_1 = require("../lib/measure");
|
|
46
46
|
const integrity_1 = require("../lib/integrity");
|
|
47
|
+
const remote_context_1 = require("../lib/remote-context");
|
|
47
48
|
const AGENT_DIRS = {
|
|
48
49
|
claude: '.claude',
|
|
49
50
|
cursor: '.cursor',
|
|
@@ -103,6 +104,52 @@ function checkClaudeHooks(projectRoot, checks) {
|
|
|
103
104
|
required: false,
|
|
104
105
|
});
|
|
105
106
|
}
|
|
107
|
+
// bet-026 — validates each configured remote_context.sources entry using the identity-only
|
|
108
|
+
// fetch (cheap: single file, not a full workspace pull), plus a separate "ever fully
|
|
109
|
+
// resolved" nudge that only applies to git sources (local paths have no persistent cache).
|
|
110
|
+
function checkRemoteContexts(projectRoot, checks) {
|
|
111
|
+
const config = (0, remote_context_1.readRemoteContextConfig)(projectRoot);
|
|
112
|
+
if (!config.enabled || config.sources.length === 0)
|
|
113
|
+
return;
|
|
114
|
+
for (const source of config.sources) {
|
|
115
|
+
const kind = (0, remote_context_1.isGitSource)(source) ? 'git' : 'path';
|
|
116
|
+
const result = (0, remote_context_1.resolveIdentityOnly)(source);
|
|
117
|
+
if (result.error) {
|
|
118
|
+
checks.push({
|
|
119
|
+
name: `remote_context: ${source.name} (${kind})`,
|
|
120
|
+
pass: false,
|
|
121
|
+
note: `${kind === 'git' ? 'Remote unreachable' : 'Path not found'}: ${result.error} — run 'oprim context list' to retry`,
|
|
122
|
+
required: false,
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!result.identity) {
|
|
127
|
+
checks.push({
|
|
128
|
+
name: `remote_context: ${source.name} (${kind})`,
|
|
129
|
+
pass: false,
|
|
130
|
+
note: `Missing remote context identity — ask the source project to run 'oprim context init'`,
|
|
131
|
+
required: false,
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
checks.push({
|
|
136
|
+
name: `remote_context: ${source.name} (${kind})`,
|
|
137
|
+
pass: true,
|
|
138
|
+
note: result.nameMismatch
|
|
139
|
+
? `Name mismatch: declared "${result.nameMismatch.declared}", resolved "${result.nameMismatch.resolved}"`
|
|
140
|
+
: undefined,
|
|
141
|
+
required: false,
|
|
142
|
+
});
|
|
143
|
+
if ((0, remote_context_1.isGitSource)(source) && !(0, remote_context_1.hasEverFullyResolved)(source)) {
|
|
144
|
+
checks.push({
|
|
145
|
+
name: `remote_context: ${source.name} never fully resolved`,
|
|
146
|
+
pass: false,
|
|
147
|
+
note: `Run 'oprim context --source ${source.name}' to pull its content for the first time`,
|
|
148
|
+
required: false,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
106
153
|
function doctorCommand() {
|
|
107
154
|
return new commander_1.Command('doctor')
|
|
108
155
|
.description('Check oprim install health and integration readiness')
|
|
@@ -209,6 +256,8 @@ function doctorCommand() {
|
|
|
209
256
|
(0, integrity_1.checkSequenceIntegrity)(projectRoot, checks);
|
|
210
257
|
// ── Skill version drift checks ────────────────────────────────────────────
|
|
211
258
|
(0, integrity_1.checkSkillVersionDrift)(projectRoot, checks);
|
|
259
|
+
// ── Remote context checks ─────────────────────────────────────────────────
|
|
260
|
+
checkRemoteContexts(projectRoot, checks);
|
|
212
261
|
// ── Agent environment checks ──────────────────────────────────────────────
|
|
213
262
|
const configAgents = (0, detect_1.readAgentsFromConfig)(projectRoot);
|
|
214
263
|
if (configAgents !== null) {
|
package/dist/lib/config-merge.js
CHANGED
|
@@ -7,7 +7,11 @@ exports.mergeSpecFramework = mergeSpecFramework;
|
|
|
7
7
|
const CONFIG_SCHEMA_FIELDS = [
|
|
8
8
|
{ key: 'context', block: 'context: ""\n' },
|
|
9
9
|
{ key: 'rules', block: 'rules: {}\n' },
|
|
10
|
-
|
|
10
|
+
// bet-026 — supersedes the old inert `store:` key (BET-025). `store` is intentionally left
|
|
11
|
+
// out of this table going forward: a project that already has it keeps it untouched (this
|
|
12
|
+
// merge only adds missing keys, never removes existing ones), but new/updated configs only
|
|
13
|
+
// ever gain `remote_context`.
|
|
14
|
+
{ key: 'remote_context', block: 'remote_context:\n enabled: false\n sources: []\n' },
|
|
11
15
|
];
|
|
12
16
|
function existingTopLevelKeys(content) {
|
|
13
17
|
const keys = new Set();
|
|
@@ -382,12 +382,14 @@ exports.CLAUDE_SKILLS = {
|
|
|
382
382
|
'oprim-review': reviewSkill(),
|
|
383
383
|
'oprim-archive': archiveSkill(),
|
|
384
384
|
'oprim-sequence': oprimSequenceSkill(),
|
|
385
|
+
'oprim-context-init': contextInitSkill(),
|
|
385
386
|
};
|
|
386
387
|
// ─── Claude command wrappers (thin, invoke skill) ────────────────────────────
|
|
387
388
|
exports.CLAUDE_COMMANDS = {
|
|
388
389
|
'promote.md': claudeWrapper('OPRIM: Promote', 'Promote a note into a bet, or a prioritized bet into a capability spec', promoteContent()),
|
|
389
390
|
'sequence.md': claudeWrapper('OPRIM: Sequence', 'Validate and update the primer sequencing board', sequenceContent()),
|
|
390
391
|
'archive.md': claudeWrapper('OPRIM: Archive', 'Archive a completed bet — move it out of the active board', archiveCommandContent()),
|
|
392
|
+
'context-init.md': claudeWrapper('OPRIM: Context Init', 'Declare the current project a citable remote context, guided by a short Q&A to draft its description', 'Use the Skill tool to invoke the `oprim-context-init` skill.'),
|
|
391
393
|
};
|
|
392
394
|
// ─── Poolside skill playbooks ─────────────────────────────────────────────────
|
|
393
395
|
exports.POOLSIDE_SKILLS = {
|
|
@@ -1057,6 +1059,43 @@ Prepend the frontmatter block from step 5b, if one was prepared.
|
|
|
1057
1059
|
### 7. Report what was created
|
|
1058
1060
|
`;
|
|
1059
1061
|
}
|
|
1062
|
+
function contextInitSkill() {
|
|
1063
|
+
return `---
|
|
1064
|
+
name: oprim-context-init
|
|
1065
|
+
description: Guide the user through drafting a description before declaring the current project a citable remote context
|
|
1066
|
+
---
|
|
1067
|
+
|
|
1068
|
+
Declare the current project a citable remote context, with a clear description other projects and agents can use to decide whether to pull it.
|
|
1069
|
+
|
|
1070
|
+
**Interactive prompts:** Use the **AskUserQuestion tool** for every question in this skill — do not write questions as plain text.
|
|
1071
|
+
|
|
1072
|
+
## What this does
|
|
1073
|
+
|
|
1074
|
+
A remote context is an oprim workspace (decisions, bets, specs) that other projects can reference read-only via \`oprim context register\`. Without a description, other projects have no cheap way to know what a remote context covers short of fully pulling it — so this skill exists to make sure one gets written.
|
|
1075
|
+
|
|
1076
|
+
## Steps
|
|
1077
|
+
|
|
1078
|
+
### 1. Check for an existing identity
|
|
1079
|
+
Check whether \`.oprim-context/context.yaml\` already exists in the current project. If it does, report that a remote context identity already exists (do not re-run the drafting flow below) and stop.
|
|
1080
|
+
|
|
1081
|
+
### 2. Ask what this workspace covers
|
|
1082
|
+
Ask the user, one at a time:
|
|
1083
|
+
- "What does this project's oprim workspace cover? (e.g. product decisions, a specific domain, a team's specs)"
|
|
1084
|
+
- "Who is this meant for — which teams or projects would reference it?"
|
|
1085
|
+
|
|
1086
|
+
If the user declines to answer either question, treat that as opting out of guided drafting — skip to step 4 with no description.
|
|
1087
|
+
|
|
1088
|
+
### 3. Draft and confirm the description
|
|
1089
|
+
From the answers, draft a single-sentence description (aim for under 120 characters — this is what \`oprim context list\` will show other projects). Show the draft to the user and ask: "Use this description? (Enter to accept, or type a replacement)"
|
|
1090
|
+
|
|
1091
|
+
### 4. Call oprim context init
|
|
1092
|
+
- If a description was drafted or accepted: use the Bash tool to run \`oprim context init --description "<final text>"\`.
|
|
1093
|
+
- If the user opted out in step 2: warn clearly that the resulting remote context will show as description-less in \`oprim context list\`, then use the Bash tool to run \`oprim context init\` with no \`--description\` flag.
|
|
1094
|
+
|
|
1095
|
+
### 5. Report what was created
|
|
1096
|
+
Report the path (\`.oprim-context/context.yaml\`) and the description that was set (or the description-less warning, if opted out).
|
|
1097
|
+
`;
|
|
1098
|
+
}
|
|
1060
1099
|
function specAuthoringSkill() {
|
|
1061
1100
|
return `---
|
|
1062
1101
|
name: oprim-spec
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface GitSource {
|
|
2
|
+
name: string;
|
|
3
|
+
git: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface PathSource {
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
export type RemoteContextSource = GitSource | PathSource;
|
|
12
|
+
export interface RemoteContextConfig {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
sources: RemoteContextSource[];
|
|
15
|
+
}
|
|
16
|
+
export interface RemoteContextIdentity {
|
|
17
|
+
name: string;
|
|
18
|
+
version: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function isGitSource(source: RemoteContextSource): source is GitSource;
|
|
22
|
+
export declare function isPathSource(source: RemoteContextSource): source is PathSource;
|
|
23
|
+
export declare function readRemoteContextConfig(projectRoot: string): RemoteContextConfig;
|
|
24
|
+
export declare function writeRemoteContextConfig(projectRoot: string, config: RemoteContextConfig): void;
|
|
25
|
+
export declare function findSourceByName(config: RemoteContextConfig, name: string): RemoteContextSource | undefined;
|
|
26
|
+
export declare function identityFilePath(rootDir: string): string;
|
|
27
|
+
export declare function readIdentity(rootDir: string): RemoteContextIdentity | null;
|
|
28
|
+
export declare function writeIdentity(rootDir: string, identity: RemoteContextIdentity): void;
|
|
29
|
+
export interface ResolvedIdentity {
|
|
30
|
+
identity: RemoteContextIdentity | null;
|
|
31
|
+
stale: boolean;
|
|
32
|
+
error?: string;
|
|
33
|
+
nameMismatch?: {
|
|
34
|
+
declared: string;
|
|
35
|
+
resolved: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface ResolvedContent {
|
|
39
|
+
workspaceRoot: string;
|
|
40
|
+
stale: boolean;
|
|
41
|
+
error?: string;
|
|
42
|
+
nameMismatch?: {
|
|
43
|
+
declared: string;
|
|
44
|
+
resolved: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export declare function resolveIdentityOnly(source: RemoteContextSource): ResolvedIdentity;
|
|
48
|
+
export declare function hasEverFullyResolved(source: RemoteContextSource): boolean;
|
|
49
|
+
export declare function resolveFull(source: RemoteContextSource): ResolvedContent;
|
|
50
|
+
export declare function assembleOprimWorkspaceContent(workspaceRoot: string): string;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.isGitSource = isGitSource;
|
|
37
|
+
exports.isPathSource = isPathSource;
|
|
38
|
+
exports.readRemoteContextConfig = readRemoteContextConfig;
|
|
39
|
+
exports.writeRemoteContextConfig = writeRemoteContextConfig;
|
|
40
|
+
exports.findSourceByName = findSourceByName;
|
|
41
|
+
exports.identityFilePath = identityFilePath;
|
|
42
|
+
exports.readIdentity = readIdentity;
|
|
43
|
+
exports.writeIdentity = writeIdentity;
|
|
44
|
+
exports.resolveIdentityOnly = resolveIdentityOnly;
|
|
45
|
+
exports.hasEverFullyResolved = hasEverFullyResolved;
|
|
46
|
+
exports.resolveFull = resolveFull;
|
|
47
|
+
exports.assembleOprimWorkspaceContent = assembleOprimWorkspaceContent;
|
|
48
|
+
const path = __importStar(require("path"));
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const os = __importStar(require("os"));
|
|
51
|
+
const crypto = __importStar(require("crypto"));
|
|
52
|
+
const child_process_1 = require("child_process");
|
|
53
|
+
const yaml = __importStar(require("js-yaml"));
|
|
54
|
+
function isGitSource(source) {
|
|
55
|
+
return 'git' in source && typeof source.git === 'string';
|
|
56
|
+
}
|
|
57
|
+
function isPathSource(source) {
|
|
58
|
+
return 'path' in source && typeof source.path === 'string';
|
|
59
|
+
}
|
|
60
|
+
// ─── oprim/config.yaml — remote_context block ──────────────────────────────
|
|
61
|
+
const REMOTE_CONTEXT_KEY = 'remote_context';
|
|
62
|
+
function configPath(projectRoot) {
|
|
63
|
+
return path.join(projectRoot, 'oprim', 'config.yaml');
|
|
64
|
+
}
|
|
65
|
+
function readRemoteContextConfig(projectRoot) {
|
|
66
|
+
const p = configPath(projectRoot);
|
|
67
|
+
if (!fs.existsSync(p))
|
|
68
|
+
return { enabled: false, sources: [] };
|
|
69
|
+
const parsed = yaml.load(fs.readFileSync(p, 'utf-8'));
|
|
70
|
+
const raw = parsed?.[REMOTE_CONTEXT_KEY];
|
|
71
|
+
if (!raw)
|
|
72
|
+
return { enabled: false, sources: [] };
|
|
73
|
+
return {
|
|
74
|
+
enabled: raw.enabled ?? false,
|
|
75
|
+
sources: Array.isArray(raw.sources) ? raw.sources : [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Surgical replace of just the `remote_context:` top-level block, leaving every other
|
|
79
|
+
// line in oprim/config.yaml byte-for-byte untouched — mirrors the non-destructive approach
|
|
80
|
+
// in config-merge.ts (never parse+re-dump the whole file).
|
|
81
|
+
function replaceTopLevelBlock(content, key, blockContent) {
|
|
82
|
+
const lines = content.split('\n');
|
|
83
|
+
const startIdx = lines.findIndex((l) => new RegExp(`^${key}:`).test(l));
|
|
84
|
+
const blockLines = blockContent.replace(/\n$/, '').split('\n');
|
|
85
|
+
if (startIdx === -1) {
|
|
86
|
+
const separator = content.endsWith('\n') || content === '' ? '' : '\n';
|
|
87
|
+
return content + separator + blockLines.join('\n') + '\n';
|
|
88
|
+
}
|
|
89
|
+
let endIdx = lines.length;
|
|
90
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
91
|
+
if (/^[A-Za-z_]/.test(lines[i])) {
|
|
92
|
+
endIdx = i;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...lines.slice(0, startIdx), ...blockLines, ...lines.slice(endIdx)].join('\n');
|
|
97
|
+
}
|
|
98
|
+
function writeRemoteContextConfig(projectRoot, config) {
|
|
99
|
+
const p = configPath(projectRoot);
|
|
100
|
+
const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
|
|
101
|
+
const dumped = yaml.dump({ [REMOTE_CONTEXT_KEY]: config }, { lineWidth: -1 });
|
|
102
|
+
const updated = replaceTopLevelBlock(existing, REMOTE_CONTEXT_KEY, dumped);
|
|
103
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
104
|
+
fs.writeFileSync(p, updated, 'utf-8');
|
|
105
|
+
}
|
|
106
|
+
function findSourceByName(config, name) {
|
|
107
|
+
return config.sources.find((s) => s.name === name);
|
|
108
|
+
}
|
|
109
|
+
// ─── Remote context identity (.oprim-context/context.yaml) ─────────────────
|
|
110
|
+
function identityFilePath(rootDir) {
|
|
111
|
+
return path.join(rootDir, '.oprim-context', 'context.yaml');
|
|
112
|
+
}
|
|
113
|
+
function readIdentity(rootDir) {
|
|
114
|
+
const p = identityFilePath(rootDir);
|
|
115
|
+
if (!fs.existsSync(p))
|
|
116
|
+
return null;
|
|
117
|
+
try {
|
|
118
|
+
const parsed = yaml.load(fs.readFileSync(p, 'utf-8'));
|
|
119
|
+
if (!parsed?.name || !parsed?.version)
|
|
120
|
+
return null;
|
|
121
|
+
return { name: parsed.name, version: parsed.version, description: parsed.description };
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function writeIdentity(rootDir, identity) {
|
|
128
|
+
const p = identityFilePath(rootDir);
|
|
129
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
130
|
+
fs.writeFileSync(p, yaml.dump(identity, { lineWidth: -1 }), 'utf-8');
|
|
131
|
+
}
|
|
132
|
+
// ─── Cache directory layout (git sources only) ─────────────────────────────
|
|
133
|
+
// 5 minutes — see design.md open question. Overridable for tests that need to exercise
|
|
134
|
+
// post-throttle-window behavior without actually waiting.
|
|
135
|
+
function throttleMs() {
|
|
136
|
+
const override = process.env['OPRIM_REMOTE_CONTEXT_THROTTLE_MS'];
|
|
137
|
+
return override ? Number(override) : 5 * 60 * 1000;
|
|
138
|
+
}
|
|
139
|
+
// Overridable so tests never write into the real user's home directory.
|
|
140
|
+
function cacheRoot() {
|
|
141
|
+
return process.env['OPRIM_REMOTE_CONTEXT_CACHE_DIR'] ?? path.join(os.homedir(), '.oprim', 'remote-context-cache');
|
|
142
|
+
}
|
|
143
|
+
function sourceCacheKey(source) {
|
|
144
|
+
const hash = crypto.createHash('sha1').update(source.git).digest('hex').slice(0, 12);
|
|
145
|
+
return `${source.name}-${hash}`;
|
|
146
|
+
}
|
|
147
|
+
function fullCacheDir(source) {
|
|
148
|
+
return path.join(cacheRoot(), sourceCacheKey(source), 'full');
|
|
149
|
+
}
|
|
150
|
+
function identityCacheDir(source) {
|
|
151
|
+
return path.join(cacheRoot(), sourceCacheKey(source), 'identity');
|
|
152
|
+
}
|
|
153
|
+
function markerPath(dir) {
|
|
154
|
+
return path.join(dir, '.oprim-last-fetch');
|
|
155
|
+
}
|
|
156
|
+
function isWithinThrottle(dir) {
|
|
157
|
+
const marker = markerPath(dir);
|
|
158
|
+
if (!fs.existsSync(marker))
|
|
159
|
+
return false;
|
|
160
|
+
const last = Number(fs.readFileSync(marker, 'utf-8').trim() || '0');
|
|
161
|
+
return Date.now() - last < throttleMs();
|
|
162
|
+
}
|
|
163
|
+
function touchMarker(dir) {
|
|
164
|
+
fs.writeFileSync(markerPath(dir), String(Date.now()), 'utf-8');
|
|
165
|
+
}
|
|
166
|
+
function git(args, cwd) {
|
|
167
|
+
(0, child_process_1.execFileSync)('git', args, { cwd, stdio: 'ignore' });
|
|
168
|
+
}
|
|
169
|
+
function withNameMismatch(source, identity) {
|
|
170
|
+
if (!identity)
|
|
171
|
+
return undefined;
|
|
172
|
+
if (identity.name === source.name)
|
|
173
|
+
return undefined;
|
|
174
|
+
return { declared: source.name, resolved: identity.name };
|
|
175
|
+
}
|
|
176
|
+
// ─── Git source resolution ─────────────────────────────────────────────────
|
|
177
|
+
function ensureFullGitClone(source) {
|
|
178
|
+
const dir = fullCacheDir(source);
|
|
179
|
+
const exists = fs.existsSync(path.join(dir, '.git'));
|
|
180
|
+
if (exists && isWithinThrottle(dir)) {
|
|
181
|
+
return { dir, stale: false };
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
if (!exists) {
|
|
185
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
186
|
+
git(['clone', '--depth', '1', '--quiet', source.git, dir]);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
git(['fetch', '--depth', '1', '--quiet', 'origin', 'HEAD'], dir);
|
|
190
|
+
git(['reset', '--hard', '--quiet', 'FETCH_HEAD'], dir);
|
|
191
|
+
}
|
|
192
|
+
touchMarker(dir);
|
|
193
|
+
return { dir, stale: false };
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if (exists) {
|
|
197
|
+
// Last-known-good fallback — resolution degrades to stale rather than failing outright.
|
|
198
|
+
return { dir, stale: true };
|
|
199
|
+
}
|
|
200
|
+
return { dir, stale: false, error: err instanceof Error ? err.message : String(err) };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function ensureIdentityOnlyGitClone(source) {
|
|
204
|
+
const dir = identityCacheDir(source);
|
|
205
|
+
const exists = fs.existsSync(path.join(dir, '.git'));
|
|
206
|
+
if (exists && isWithinThrottle(dir)) {
|
|
207
|
+
return { dir };
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
if (!exists) {
|
|
211
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
212
|
+
// Partial clone (blob:none) + cone sparse-checkout scoped to .oprim-context/ — this is
|
|
213
|
+
// the "targeted single-file fetch" from design.md decision 5, implemented as an
|
|
214
|
+
// equivalent narrow fetch: git only downloads the one blob it needs to check out,
|
|
215
|
+
// not the whole workspace. `git archive --remote` was considered and rejected — GitHub
|
|
216
|
+
// and most managed git hosts disable the upload-archive service, so it isn't portable.
|
|
217
|
+
git(['clone', '--filter=blob:none', '--no-checkout', '--depth', '1', '--quiet', source.git, dir]);
|
|
218
|
+
git(['sparse-checkout', 'init', '--cone'], dir);
|
|
219
|
+
git(['sparse-checkout', 'set', '.oprim-context'], dir);
|
|
220
|
+
git(['checkout', '--quiet'], dir);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
git(['fetch', '--depth', '1', '--quiet', 'origin', 'HEAD'], dir);
|
|
224
|
+
git(['reset', '--hard', '--quiet', 'FETCH_HEAD'], dir);
|
|
225
|
+
}
|
|
226
|
+
touchMarker(dir);
|
|
227
|
+
return { dir };
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
return { dir, error: err instanceof Error ? err.message : String(err) };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function resolveIdentityOnly(source) {
|
|
234
|
+
if (isPathSource(source)) {
|
|
235
|
+
if (!fs.existsSync(source.path)) {
|
|
236
|
+
return { identity: null, stale: false, error: `path not found: ${source.path}` };
|
|
237
|
+
}
|
|
238
|
+
const identity = readIdentity(source.path);
|
|
239
|
+
return { identity, stale: false, nameMismatch: withNameMismatch(source, identity) };
|
|
240
|
+
}
|
|
241
|
+
const { dir, error } = ensureIdentityOnlyGitClone(source);
|
|
242
|
+
if (error)
|
|
243
|
+
return { identity: null, stale: false, error };
|
|
244
|
+
const identity = readIdentity(dir);
|
|
245
|
+
return { identity, stale: false, nameMismatch: withNameMismatch(source, identity) };
|
|
246
|
+
}
|
|
247
|
+
// Local-path sources are always read live (no persistent cache), so "has this ever been
|
|
248
|
+
// resolved" is only a meaningful question for git sources, where full resolution populates
|
|
249
|
+
// a durable cache directory that outlives any single command invocation.
|
|
250
|
+
function hasEverFullyResolved(source) {
|
|
251
|
+
if (isPathSource(source))
|
|
252
|
+
return true;
|
|
253
|
+
return fs.existsSync(path.join(fullCacheDir(source), '.git'));
|
|
254
|
+
}
|
|
255
|
+
function resolveFull(source) {
|
|
256
|
+
if (isPathSource(source)) {
|
|
257
|
+
if (!fs.existsSync(source.path)) {
|
|
258
|
+
return { workspaceRoot: source.path, stale: false, error: `path not found: ${source.path}` };
|
|
259
|
+
}
|
|
260
|
+
const identity = readIdentity(source.path);
|
|
261
|
+
return {
|
|
262
|
+
workspaceRoot: source.path,
|
|
263
|
+
stale: false,
|
|
264
|
+
nameMismatch: withNameMismatch(source, identity),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const { dir, stale, error } = ensureFullGitClone(source);
|
|
268
|
+
if (error)
|
|
269
|
+
return { workspaceRoot: dir, stale: false, error };
|
|
270
|
+
const identity = readIdentity(dir);
|
|
271
|
+
return { workspaceRoot: dir, stale, nameMismatch: withNameMismatch(source, identity) };
|
|
272
|
+
}
|
|
273
|
+
// ─── Assembling oprim/ workspace content for printing ──────────────────────
|
|
274
|
+
function walkTextFiles(dir, base, out) {
|
|
275
|
+
if (!fs.existsSync(dir))
|
|
276
|
+
return;
|
|
277
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
278
|
+
const full = path.join(dir, entry.name);
|
|
279
|
+
const rel = path.join(base, entry.name);
|
|
280
|
+
if (entry.isDirectory()) {
|
|
281
|
+
walkTextFiles(full, rel, out);
|
|
282
|
+
}
|
|
283
|
+
else if (entry.isFile() && /\.(md|yaml|yml)$/.test(entry.name)) {
|
|
284
|
+
out.push(rel);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function assembleOprimWorkspaceContent(workspaceRoot) {
|
|
289
|
+
const oprimDir = path.join(workspaceRoot, 'oprim');
|
|
290
|
+
const files = [];
|
|
291
|
+
walkTextFiles(oprimDir, 'oprim', files);
|
|
292
|
+
files.sort();
|
|
293
|
+
return files
|
|
294
|
+
.map((rel) => {
|
|
295
|
+
const content = fs.readFileSync(path.join(workspaceRoot, rel), 'utf-8');
|
|
296
|
+
return `─── ${rel} ───\n${content}`;
|
|
297
|
+
})
|
|
298
|
+
.join('\n\n');
|
|
299
|
+
}
|
package/dist/lib/templates.js
CHANGED