create-loopengine 0.1.13 → 0.1.14
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 +36 -0
- package/dist/cli.d.ts +27 -0
- package/dist/cli.js +169 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ agents/example-agent.ts Starter agent: persona, one tool, one permission rule
|
|
|
51
51
|
adapters/http.ts HTTP API (streaming + non-streaming), routes by agent name
|
|
52
52
|
adapters/cli.ts Command-line adapter
|
|
53
53
|
.env.example ANTHROPIC_API_KEY / OPENAI_API_KEY / REDIS_URL
|
|
54
|
+
.create-loopengine.json Which version this project was scaffolded from — see "Upgrading" below
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
The registry is multi-agent-capable from the start — seeded with one
|
|
@@ -84,6 +85,41 @@ working example. Not wired in here by default: it needs the `composio` CLI
|
|
|
84
85
|
installed and an authenticated account (`composio link <toolkit>`) first,
|
|
85
86
|
so it's a deliberate next step, not part of this starter agent.
|
|
86
87
|
|
|
88
|
+
## Upgrading
|
|
89
|
+
|
|
90
|
+
`npm install loopengine@latest` alone only picks up library-side changes
|
|
91
|
+
— `adapters/http.ts`, `adapters/cli.ts`, and `agent-registry.ts` are
|
|
92
|
+
copied into your project once at scaffold time and never touched by npm
|
|
93
|
+
again, so template improvements (new routes, bug fixes) don't reach an
|
|
94
|
+
existing project on their own. `upgrade` closes that gap with a real
|
|
95
|
+
three-way merge — the same technique `git merge` uses — so your own
|
|
96
|
+
edits to those files survive:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx create-loopengine@latest upgrade
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Run from inside your project. It merges the current template against
|
|
103
|
+
whichever `create-loopengine` version your project was originally
|
|
104
|
+
scaffolded from (recorded in `.create-loopengine.json`, written
|
|
105
|
+
automatically at scaffold time — pass `--from <version>` if that file is
|
|
106
|
+
missing, e.g. for a project scaffolded before this command existed).
|
|
107
|
+
Each of the five template-owned files gets one of:
|
|
108
|
+
|
|
109
|
+
- **unchanged** — the template never touched this file between your
|
|
110
|
+
version and the latest; nothing to merge, nothing overwritten.
|
|
111
|
+
- **updated** — merged cleanly; your own edits and the template's own
|
|
112
|
+
changes didn't overlap.
|
|
113
|
+
- **conflict** — you edited the same lines the template changed. The
|
|
114
|
+
file is written with real `<<<<<<<`/`=======`/`>>>>>>>` markers, same
|
|
115
|
+
as any git merge conflict — resolve them by hand, then `npx tsc
|
|
116
|
+
--noEmit` to confirm it builds.
|
|
117
|
+
|
|
118
|
+
`upgrade` never touches `agents/`, `README.md`, `.env.example`, or
|
|
119
|
+
`package.json`'s own dependency versions — run `npm install
|
|
120
|
+
loopengine@latest` (and `actauth`/`skillgarden` if you use them directly)
|
|
121
|
+
separately to pick those up.
|
|
122
|
+
|
|
87
123
|
## Status
|
|
88
124
|
|
|
89
125
|
Verified end to end against the real published packages: `npm create
|
package/dist/cli.d.ts
CHANGED
|
@@ -6,4 +6,31 @@ export interface ScaffoldOptions {
|
|
|
6
6
|
templateDir?: string;
|
|
7
7
|
}
|
|
8
8
|
export declare function scaffold(options: ScaffoldOptions): string;
|
|
9
|
+
export type UpgradeFileStatus = 'updated' | 'unchanged' | 'conflict' | 'skipped';
|
|
10
|
+
export interface UpgradeFileResult {
|
|
11
|
+
file: string;
|
|
12
|
+
status: UpgradeFileStatus;
|
|
13
|
+
}
|
|
14
|
+
export interface UpgradeResult {
|
|
15
|
+
fromVersion: string;
|
|
16
|
+
toVersion: string;
|
|
17
|
+
files: UpgradeFileResult[];
|
|
18
|
+
}
|
|
19
|
+
export interface UpgradeOptions {
|
|
20
|
+
/** Defaults to process.cwd(). */
|
|
21
|
+
projectDir?: string;
|
|
22
|
+
/** Overrides the version recorded in .create-loopengine.json — required
|
|
23
|
+
* if that file is missing (a project scaffolded before this command
|
|
24
|
+
* existed) or was removed. */
|
|
25
|
+
from?: string;
|
|
26
|
+
/** Test-only override for the "current" template — defaults to this
|
|
27
|
+
* CLI's own bundled template/, same as ScaffoldOptions.templateDir. */
|
|
28
|
+
currentTemplateDir?: string;
|
|
29
|
+
/** Resolves the historical template directory for a given
|
|
30
|
+
* create-loopengine version — defaults to fetchPublishedTemplateDir
|
|
31
|
+
* (a real `npm pack`). Test-only seam to avoid hitting the real
|
|
32
|
+
* registry for a fixture "old" template. */
|
|
33
|
+
resolveBaseTemplateDir?: (version: string) => string;
|
|
34
|
+
}
|
|
35
|
+
export declare function upgradeProject(options?: UpgradeOptions): UpgradeResult;
|
|
9
36
|
export declare function main(argv?: string[]): void;
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
3
5
|
import { dirname, join } from 'node:path';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
// Template ships in `template/` next to `dist/`, not inside it — it's a
|
|
6
8
|
// real, buildable project (its own package.json/tsconfig), not TypeScript
|
|
7
9
|
// source belonging to this CLI.
|
|
8
10
|
const DEFAULT_TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'template');
|
|
11
|
+
// This CLI's own version — embedded in a scaffolded project's own
|
|
12
|
+
// .create-loopengine.json (see scaffold() below) as the future upgrade
|
|
13
|
+
// command's merge base, and used again here as the "to" version once
|
|
14
|
+
// upgradeProject() actually runs. Read at runtime rather than baked in
|
|
15
|
+
// at build time so a locally-linked/packed build always reports its own
|
|
16
|
+
// real package.json, not a stale constant.
|
|
17
|
+
function ownVersion() {
|
|
18
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
19
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
20
|
+
}
|
|
9
21
|
export function scaffold(options) {
|
|
10
22
|
const { name } = options;
|
|
11
23
|
const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
|
|
@@ -25,13 +37,169 @@ export function scaffold(options) {
|
|
|
25
37
|
const readmePath = join(destination, 'README.md');
|
|
26
38
|
const readme = readFileSync(readmePath, 'utf8').replace('__PROJECT_NAME__', name);
|
|
27
39
|
writeFileSync(readmePath, readme);
|
|
40
|
+
// Records which create-loopengine version this project's copied,
|
|
41
|
+
// hand-editable files (adapters/http.ts, agent-registry.ts, ...) last
|
|
42
|
+
// matched — the merge base `upgrade` needs to pull in template changes
|
|
43
|
+
// later without clobbering whatever's been hand-edited since. Tracked
|
|
44
|
+
// like package.json/lockfiles (committed, not gitignored): it's project
|
|
45
|
+
// metadata, not a build artifact.
|
|
46
|
+
writeFileSync(join(destination, '.create-loopengine.json'), JSON.stringify({ version: ownVersion() }, null, 2) + '\n');
|
|
28
47
|
return destination;
|
|
29
48
|
}
|
|
49
|
+
// ---- upgrade: pulls template improvements into an already-scaffolded
|
|
50
|
+
// project's own copied files (adapters/http.ts, adapters/cli.ts,
|
|
51
|
+
// agent-registry.ts, tsconfig.json, .gitignore) via a real three-way
|
|
52
|
+
// merge — the same technique `git merge`/`rebase` use, applied here
|
|
53
|
+
// because those files are meant to be hand-edited after scaffolding, not
|
|
54
|
+
// re-imported from a package the way core library code is. See
|
|
55
|
+
// scaffold()'s own .create-loopengine.json write above for the "base"
|
|
56
|
+
// version this merge starts from. ----
|
|
57
|
+
// templatePath is where the file lives inside a template/ directory
|
|
58
|
+
// (base or current); projectPath is where the *scaffolded* copy lives —
|
|
59
|
+
// only differs for _gitignore, which scaffold() renames to .gitignore
|
|
60
|
+
// (see its own comment for why the template can't ship it under that
|
|
61
|
+
// name directly). Deliberately narrow: agents/example-agent/** stops
|
|
62
|
+
// being "template" the instant someone builds a real agent on top of it,
|
|
63
|
+
// and package.json's own dependency versions are a separate, simpler
|
|
64
|
+
// `npm install loopengine@latest` left to the operator rather than
|
|
65
|
+
// merged here.
|
|
66
|
+
const UPGRADE_CANDIDATE_FILES = [
|
|
67
|
+
{ templatePath: 'adapters/http.ts', projectPath: 'adapters/http.ts' },
|
|
68
|
+
{ templatePath: 'adapters/cli.ts', projectPath: 'adapters/cli.ts' },
|
|
69
|
+
{ templatePath: 'agent-registry.ts', projectPath: 'agent-registry.ts' },
|
|
70
|
+
{ templatePath: 'tsconfig.json', projectPath: 'tsconfig.json' },
|
|
71
|
+
{ templatePath: '_gitignore', projectPath: '.gitignore' },
|
|
72
|
+
];
|
|
73
|
+
// npm keeps every published tarball forever, so the historical template
|
|
74
|
+
// for *any* previously-released version is always fetchable this way —
|
|
75
|
+
// no separate snapshot storage, no git-tag dependency. `npm pack`
|
|
76
|
+
// extracts to a `package/` directory inside whatever `--pack-destination`
|
|
77
|
+
// it's given, same as every other real-package verification already done
|
|
78
|
+
// this way elsewhere in this project's own test suite.
|
|
79
|
+
function fetchPublishedTemplateDir(version) {
|
|
80
|
+
const workDir = mkdtempSync(join(tmpdir(), 'create-loopengine-upgrade-base-'));
|
|
81
|
+
execFileSync('npm', ['pack', `create-loopengine@${version}`, '--pack-destination', workDir], { stdio: 'pipe' });
|
|
82
|
+
const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
|
|
83
|
+
if (!tarball) {
|
|
84
|
+
throw new Error(`Could not fetch create-loopengine@${version} from npm — check the version exists and you have network access.`);
|
|
85
|
+
}
|
|
86
|
+
// Absolute path, not the bare filename readdirSync returns — tar
|
|
87
|
+
// resolves a relative first argument against the calling process's own
|
|
88
|
+
// cwd (wherever `create-loopengine upgrade` was actually run from), not
|
|
89
|
+
// workDir, even with -C set (-C only affects where extraction lands).
|
|
90
|
+
// Confirmed live: this failed with a wrong-directory ENOENT.
|
|
91
|
+
execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
|
|
92
|
+
return join(workDir, 'package', 'template');
|
|
93
|
+
}
|
|
94
|
+
// git merge-file's own exit code *is* its conflict count (0 = clean),
|
|
95
|
+
// not a pass/fail signal — execFileSync throws on any non-zero exit, so
|
|
96
|
+
// a real conflict has to be told apart from git genuinely failing to
|
|
97
|
+
// run (not installed, path error: no `status`, an `ENOENT`-shaped error
|
|
98
|
+
// instead) by checking for a numeric `status` on the caught error.
|
|
99
|
+
// Operates on `minePath` in place (git merge-file's own contract) — always
|
|
100
|
+
// called with a disposable temp copy, never the real project file
|
|
101
|
+
// directly, so the caller decides whether/how to apply the result.
|
|
102
|
+
function threeWayMerge(minePath, basePath, theirsPath) {
|
|
103
|
+
let conflicted = false;
|
|
104
|
+
try {
|
|
105
|
+
execFileSync('git', ['merge-file', '--diff3', '-L', 'mine', '-L', 'base', '-L', 'latest', minePath, basePath, theirsPath], {
|
|
106
|
+
stdio: 'pipe',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') {
|
|
111
|
+
conflicted = true;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw new Error(`git merge-file failed — is git installed and on PATH? (${err instanceof Error ? err.message : String(err)})`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { merged: readFileSync(minePath, 'utf8'), conflicted };
|
|
118
|
+
}
|
|
119
|
+
function upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir) {
|
|
120
|
+
const projectFilePath = join(projectDir, candidate.projectPath);
|
|
121
|
+
const baseFilePath = join(baseTemplateDir, candidate.templatePath);
|
|
122
|
+
const theirsFilePath = join(currentTemplateDir, candidate.templatePath);
|
|
123
|
+
if (!existsSync(projectFilePath) || !existsSync(baseFilePath) || !existsSync(theirsFilePath)) {
|
|
124
|
+
return { file: candidate.projectPath, status: 'skipped' };
|
|
125
|
+
}
|
|
126
|
+
const base = readFileSync(baseFilePath, 'utf8');
|
|
127
|
+
const theirs = readFileSync(theirsFilePath, 'utf8');
|
|
128
|
+
if (base === theirs) {
|
|
129
|
+
// The template never touched this file between the recorded version
|
|
130
|
+
// and now — nothing to merge, and importantly nothing to overwrite,
|
|
131
|
+
// so a hand-edit here is never even diffed against, let alone at risk.
|
|
132
|
+
return { file: candidate.projectPath, status: 'unchanged' };
|
|
133
|
+
}
|
|
134
|
+
const mine = readFileSync(projectFilePath, 'utf8');
|
|
135
|
+
const scratchDir = mkdtempSync(join(tmpdir(), 'create-loopengine-merge-'));
|
|
136
|
+
const mineTmp = join(scratchDir, 'mine');
|
|
137
|
+
const baseTmp = join(scratchDir, 'base');
|
|
138
|
+
const theirsTmp = join(scratchDir, 'theirs');
|
|
139
|
+
writeFileSync(mineTmp, mine);
|
|
140
|
+
writeFileSync(baseTmp, base);
|
|
141
|
+
writeFileSync(theirsTmp, theirs);
|
|
142
|
+
const { merged, conflicted } = threeWayMerge(mineTmp, baseTmp, theirsTmp);
|
|
143
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
144
|
+
writeFileSync(projectFilePath, merged);
|
|
145
|
+
return { file: candidate.projectPath, status: conflicted ? 'conflict' : 'updated' };
|
|
146
|
+
}
|
|
147
|
+
export function upgradeProject(options = {}) {
|
|
148
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
149
|
+
const currentTemplateDir = options.currentTemplateDir ?? DEFAULT_TEMPLATE_DIR;
|
|
150
|
+
const toVersion = ownVersion();
|
|
151
|
+
const provenancePath = join(projectDir, '.create-loopengine.json');
|
|
152
|
+
let fromVersion = options.from;
|
|
153
|
+
if (!fromVersion) {
|
|
154
|
+
if (!existsSync(provenancePath)) {
|
|
155
|
+
throw new Error(`No .create-loopengine.json in ${projectDir} — this project was scaffolded before \`upgrade\` existed, or the file was removed. Pass --from <version> to specify which create-loopengine version it was originally scaffolded from.`);
|
|
156
|
+
}
|
|
157
|
+
const provenance = JSON.parse(readFileSync(provenancePath, 'utf8'));
|
|
158
|
+
if (!provenance.version) {
|
|
159
|
+
throw new Error(`${provenancePath} is missing its "version" field — pass --from <version> instead.`);
|
|
160
|
+
}
|
|
161
|
+
fromVersion = provenance.version;
|
|
162
|
+
}
|
|
163
|
+
const resolveBase = options.resolveBaseTemplateDir ?? fetchPublishedTemplateDir;
|
|
164
|
+
const baseTemplateDir = resolveBase(fromVersion);
|
|
165
|
+
const files = UPGRADE_CANDIDATE_FILES.map((candidate) => upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir));
|
|
166
|
+
writeFileSync(provenancePath, JSON.stringify({ version: toVersion }, null, 2) + '\n');
|
|
167
|
+
return { fromVersion, toVersion, files };
|
|
168
|
+
}
|
|
169
|
+
function parseUpgradeArgs(argv) {
|
|
170
|
+
let from;
|
|
171
|
+
for (let i = 0; i < argv.length; i++) {
|
|
172
|
+
if (argv[i] === '--from')
|
|
173
|
+
from = argv[++i];
|
|
174
|
+
}
|
|
175
|
+
return { from };
|
|
176
|
+
}
|
|
177
|
+
function printUpgradeSummary(result) {
|
|
178
|
+
console.log(`Upgrading create-loopengine@${result.fromVersion} -> @${result.toVersion}`);
|
|
179
|
+
console.log();
|
|
180
|
+
for (const f of result.files) {
|
|
181
|
+
const label = f.status === 'updated' ? 'updated ' : f.status === 'conflict' ? 'conflict ' : f.status === 'unchanged' ? 'unchanged' : 'skipped ';
|
|
182
|
+
console.log(` ${label} ${f.file}`);
|
|
183
|
+
}
|
|
184
|
+
console.log();
|
|
185
|
+
const conflicted = result.files.filter((f) => f.status === 'conflict');
|
|
186
|
+
if (conflicted.length > 0) {
|
|
187
|
+
console.log(`${conflicted.length} file(s) have <<<<<<< conflict markers to resolve by hand before this will build.`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
console.log('No conflicts. Run `npm install loopengine@latest` to pick up any library-side changes too.');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
30
193
|
function parseArgs(argv) {
|
|
31
194
|
return { name: argv[0] ?? 'my-agents' };
|
|
32
195
|
}
|
|
33
196
|
export function main(argv = process.argv.slice(2)) {
|
|
34
197
|
try {
|
|
198
|
+
if (argv[0] === 'upgrade') {
|
|
199
|
+
const { from } = parseUpgradeArgs(argv.slice(1));
|
|
200
|
+
printUpgradeSummary(upgradeProject({ from }));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
35
203
|
const { name } = parseArgs(argv);
|
|
36
204
|
const destination = scaffold({ name });
|
|
37
205
|
console.log(`Created ${destination}/`);
|
package/package.json
CHANGED