@litfamily/litopencode 1.0.17 → 1.0.19
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/CHANGELOG.md +16 -0
- package/README-Ko-KR.md +56 -86
- package/README.md +57 -86
- package/dist/activation-routing.js +3 -1
- package/dist/cli/args.js +5 -1
- package/dist/cli/auto-update.js +1 -1
- package/dist/cli/doctor.js +7 -5
- package/dist/cli/host-limits.js +2 -5
- package/dist/cli/install-config.d.ts +4 -0
- package/dist/cli/install-config.js +27 -1
- package/dist/cli/install-report.js +4 -1
- package/dist/cli/install.js +89 -28
- package/dist/cli/json.d.ts +1 -0
- package/dist/cli/json.js +10 -4
- package/dist/cli/jsonc.d.ts +17 -0
- package/dist/cli/jsonc.js +174 -0
- package/dist/cli/loop.d.ts +1 -1
- package/dist/cli/loop.js +81 -1
- package/dist/cli/motion-runtime.d.ts +13 -1
- package/dist/cli/motion-runtime.js +89 -2
- package/dist/cli/plugin-mutation.js +11 -6
- package/dist/cli/tui-config.d.ts +52 -0
- package/dist/cli/tui-config.js +198 -0
- package/dist/cli/types.d.ts +4 -0
- package/dist/cli.js +8 -3
- package/dist/deliverable-hedge-guard.js +5 -3
- package/dist/hooks.js +2 -1
- package/dist/tui/mission-band.d.ts +29 -0
- package/dist/tui/mission-band.js +116 -0
- package/dist/tui/mission-model.d.ts +139 -0
- package/dist/tui/mission-model.js +318 -0
- package/dist/tui/mission-motion.d.ts +66 -0
- package/dist/tui/mission-motion.js +147 -0
- package/dist/tui/mission-source.d.ts +56 -0
- package/dist/tui/mission-source.js +88 -0
- package/dist/tui/mission-text.d.ts +103 -0
- package/dist/tui/mission-text.js +613 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +317 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +72 -1
- package/docs/reference.md +82 -1
- package/package.json +21 -2
- package/skills/lit-docx/scripts/docx_design.py +1 -1
- package/skills/lit-docx/templates/tonalities/brief.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/journal.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/manual.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/memo.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/proposal.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/report.yaml +0 -1
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -4
- package/skills/lit-pptx/references/tonalities/chalk.md +2 -2
- package/skills/lit-pptx/references/tonalities/gazette.md +3 -3
- package/skills/lit-pptx/references/tonalities/ledger.md +6 -7
- package/skills/lit-pptx/references/tonalities/night.md +3 -3
- package/skills/lit-pptx/references/tonalities/paper.md +4 -5
- package/skills/lit-pptx/references/tonalities/signal.md +3 -4
- package/skills/lit-pptx/references/tonalities/studio.md +3 -3
- package/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
- package/skills/lit-pptx/scripts/validate_pptx.py +10 -4
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -14
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -12
- package/skills/managed-skill-manifest.json +25 -25
- package/tools/check-pack-payload.mjs +20 -5
- package/tools/version-manifests.json +18 -6
|
@@ -9,18 +9,23 @@ function pluginEntryId(value) {
|
|
|
9
9
|
return value[0];
|
|
10
10
|
return "";
|
|
11
11
|
}
|
|
12
|
+
// The install target counts as a LitOpenCode entry even when it is a local checkout path, which the
|
|
13
|
+
// package-name match cannot recognize; otherwise a re-install would append the same path again.
|
|
14
|
+
function isRegistrationEntry(value, target) {
|
|
15
|
+
return isLitOpenCodeEntry(value) || pluginEntryId(value) === target;
|
|
16
|
+
}
|
|
12
17
|
function replaceLitOpenCodeEntry(value, target) {
|
|
13
|
-
if (Array.isArray(value)
|
|
18
|
+
if (Array.isArray(value))
|
|
14
19
|
return [target, ...value.slice(1)];
|
|
15
20
|
return target;
|
|
16
21
|
}
|
|
17
22
|
function normalizedPluginArray(pluginValue, target) {
|
|
18
|
-
const firstLitEntry = pluginValue.find(
|
|
23
|
+
const firstLitEntry = pluginValue.find((value) => isRegistrationEntry(value, target));
|
|
19
24
|
const replacement = firstLitEntry === undefined ? target : replaceLitOpenCodeEntry(firstLitEntry, target);
|
|
20
25
|
let replaced = false;
|
|
21
26
|
const next = [];
|
|
22
27
|
for (const value of pluginValue) {
|
|
23
|
-
if (!
|
|
28
|
+
if (!isRegistrationEntry(value, target)) {
|
|
24
29
|
next.push(value);
|
|
25
30
|
continue;
|
|
26
31
|
}
|
|
@@ -34,9 +39,9 @@ function normalizedPluginArray(pluginValue, target) {
|
|
|
34
39
|
export function describePluginMutation(config, target) {
|
|
35
40
|
const pluginValue = config?.plugin;
|
|
36
41
|
const pluginIsArray = Array.isArray(pluginValue);
|
|
37
|
-
const existingIndex = pluginIsArray ? pluginValue.findIndex(
|
|
42
|
+
const existingIndex = pluginIsArray ? pluginValue.findIndex((value) => isRegistrationEntry(value, target)) : -1;
|
|
38
43
|
const targetIndex = pluginIsArray ? pluginValue.findIndex((value) => pluginEntryId(value) === target) : -1;
|
|
39
|
-
const litEntryCount = pluginIsArray ? pluginValue.filter(
|
|
44
|
+
const litEntryCount = pluginIsArray ? pluginValue.filter((value) => isRegistrationEntry(value, target)).length : 0;
|
|
40
45
|
const alreadyPresent = targetIndex >= 0 && litEntryCount === 1;
|
|
41
46
|
const currentCount = pluginIsArray ? pluginValue.length : 0;
|
|
42
47
|
if (alreadyPresent) {
|
|
@@ -81,7 +86,7 @@ export function applyPluginMutation(config, mutation, target) {
|
|
|
81
86
|
return next;
|
|
82
87
|
const pluginValue = next.plugin;
|
|
83
88
|
if (Array.isArray(pluginValue)) {
|
|
84
|
-
next.plugin = pluginValue.some(
|
|
89
|
+
next.plugin = pluginValue.some((value) => isRegistrationEntry(value, target)) ? normalizedPluginArray(pluginValue, target) : [...pluginValue, target];
|
|
85
90
|
return next;
|
|
86
91
|
}
|
|
87
92
|
next.plugin = [target];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export declare const tuiSchemaUrl = "https://opencode.ai/tui.json";
|
|
2
|
+
export declare const tuiBackupSuffix = ".litopencode-backup";
|
|
3
|
+
type PlannedWrite = {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly before: string | null;
|
|
6
|
+
readonly text: string;
|
|
7
|
+
};
|
|
8
|
+
export type TuiInstallReport = {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly alreadyPresent: boolean;
|
|
11
|
+
readonly changed: boolean;
|
|
12
|
+
readonly add: readonly string[];
|
|
13
|
+
readonly currentCount: number;
|
|
14
|
+
readonly resultCount: number;
|
|
15
|
+
readonly cleaned: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
export type TuiInstallPlan = {
|
|
18
|
+
readonly report: TuiInstallReport;
|
|
19
|
+
readonly writes: readonly PlannedWrite[];
|
|
20
|
+
readonly blocked?: string;
|
|
21
|
+
};
|
|
22
|
+
export type TuiRemovalReport = {
|
|
23
|
+
readonly dryRun: boolean;
|
|
24
|
+
readonly removed: number;
|
|
25
|
+
readonly files: readonly {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly removed: number;
|
|
28
|
+
readonly backup?: string;
|
|
29
|
+
}[];
|
|
30
|
+
};
|
|
31
|
+
export declare function tuiConfigCandidates(root: string): readonly [string, string];
|
|
32
|
+
/** Works out every TUI config edit an install makes, without writing. A comment that would be lost blocks the plan. */
|
|
33
|
+
export declare function planTuiInstall(root: string, target: string): Promise<TuiInstallPlan>;
|
|
34
|
+
/** Writes a plan: each file that already existed is copied to `<file>.litopencode-backup` first. */
|
|
35
|
+
export declare function applyTuiWrites(writes: readonly PlannedWrite[], options?: {
|
|
36
|
+
readonly backup?: boolean;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
/** Backs up the TUI files a plan is about to change, for writers outside LitOpenCode such as `opencode plugin`. */
|
|
39
|
+
export declare function backupTuiFiles(plan: TuiInstallPlan): Promise<void>;
|
|
40
|
+
/** Removes LitOpenCode's own TUI entries, and only those, from tui.json and tui.jsonc. */
|
|
41
|
+
export declare function removeTuiPlugin(root: string, dryRun: boolean): Promise<TuiRemovalReport>;
|
|
42
|
+
export type TuiInspection = {
|
|
43
|
+
readonly path: string;
|
|
44
|
+
readonly expected: string;
|
|
45
|
+
readonly configured: string | null;
|
|
46
|
+
readonly present: boolean;
|
|
47
|
+
readonly entries: number;
|
|
48
|
+
readonly ok: boolean;
|
|
49
|
+
readonly error?: string;
|
|
50
|
+
};
|
|
51
|
+
export declare function inspectTuiPlugin(root: string, expected: string): Promise<TuiInspection>;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { OpenCodeConfigCommentError, renderOpenCodeConfig, writeTextAtomically } from "./install-config.js";
|
|
4
|
+
import { isRecord, readJsonObjectIfPresent, readTextIfPresent } from "./json.js";
|
|
5
|
+
import { parseJsonc } from "./jsonc.js";
|
|
6
|
+
import { isLitOpenCodeEntry } from "./plugin-mutation.js";
|
|
7
|
+
// OpenCode reads TUI plugins from the `plugin` list of tui.json and tui.jsonc beside opencode.json,
|
|
8
|
+
// merging both. When it adds an entry itself it edits the first of the two that exists, else creates
|
|
9
|
+
// tui.json; LitOpenCode follows the same choice so a host install and ours never split the entry.
|
|
10
|
+
export const tuiSchemaUrl = "https://opencode.ai/tui.json";
|
|
11
|
+
export const tuiBackupSuffix = ".litopencode-backup";
|
|
12
|
+
export function tuiConfigCandidates(root) {
|
|
13
|
+
const resolved = path.resolve(root);
|
|
14
|
+
return [path.join(resolved, "tui.json"), path.join(resolved, "tui.jsonc")];
|
|
15
|
+
}
|
|
16
|
+
function entryId(value) {
|
|
17
|
+
if (typeof value === "string")
|
|
18
|
+
return value;
|
|
19
|
+
if (Array.isArray(value) && typeof value[0] === "string")
|
|
20
|
+
return value[0];
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
async function isLitOpenCodePathEntry(root, id) {
|
|
24
|
+
if (!(path.isAbsolute(id) || id.startsWith(".")))
|
|
25
|
+
return false;
|
|
26
|
+
const metadata = await readJsonObjectIfPresent(path.join(path.resolve(root, id), "package.json")).catch(() => null);
|
|
27
|
+
return metadata?.name === "litopencode" || metadata?.name === "@litfamily/opencode" || metadata?.name === "@litfamily/litopencode";
|
|
28
|
+
}
|
|
29
|
+
async function isOwnEntry(root, value, target) {
|
|
30
|
+
const id = entryId(value);
|
|
31
|
+
return id !== "" && (id === target || isLitOpenCodeEntry(value) || (await isLitOpenCodePathEntry(root, id)));
|
|
32
|
+
}
|
|
33
|
+
async function readTuiFile(file) {
|
|
34
|
+
const text = await readTextIfPresent(file);
|
|
35
|
+
if (text === null)
|
|
36
|
+
return { path: file, text, config: null };
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = parseJsonc(text);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(`Malformed JSONC at ${file}: ${error instanceof Error ? error.message : String(error)}. Nothing was written.`);
|
|
43
|
+
}
|
|
44
|
+
if (!isRecord(parsed))
|
|
45
|
+
throw new Error(`Malformed JSON at ${file}: expected an object. Nothing was written.`);
|
|
46
|
+
return { path: file, text, config: parsed };
|
|
47
|
+
}
|
|
48
|
+
async function ownIndexes(root, plugins, target) {
|
|
49
|
+
const indexes = [];
|
|
50
|
+
for (const [index, value] of plugins.entries())
|
|
51
|
+
if (await isOwnEntry(root, value, target))
|
|
52
|
+
indexes.push(index);
|
|
53
|
+
return indexes;
|
|
54
|
+
}
|
|
55
|
+
function pluginList(config) {
|
|
56
|
+
return Array.isArray(config?.plugin) ? config.plugin : [];
|
|
57
|
+
}
|
|
58
|
+
function render(file, next) {
|
|
59
|
+
return { path: file.path, before: file.text, text: renderOpenCodeConfig(file.path, file.text, next) };
|
|
60
|
+
}
|
|
61
|
+
/** Works out every TUI config edit an install makes, without writing. A comment that would be lost blocks the plan. */
|
|
62
|
+
export async function planTuiInstall(root, target) {
|
|
63
|
+
const files = await Promise.all(tuiConfigCandidates(root).map(readTuiFile));
|
|
64
|
+
const owned = await Promise.all(files.map(async (file) => ownIndexes(root, pluginList(file.config), target)));
|
|
65
|
+
const holder = files.findIndex((_file, index) => owned[index].length > 0);
|
|
66
|
+
const primaryIndex = holder >= 0 ? holder : Math.max(0, files.findIndex((file) => file.text !== null));
|
|
67
|
+
const primary = files[primaryIndex];
|
|
68
|
+
const current = pluginList(primary.config);
|
|
69
|
+
const primaryOwned = owned[primaryIndex];
|
|
70
|
+
const alreadyPresent = primaryOwned.length === 1 && entryId(current[primaryOwned[0]]) === target;
|
|
71
|
+
let nextPlugins;
|
|
72
|
+
if (primaryOwned.length === 0) {
|
|
73
|
+
nextPlugins = [...current, target];
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const first = current[primaryOwned[0]];
|
|
77
|
+
const replacement = Array.isArray(first) ? [target, ...first.slice(1)] : target;
|
|
78
|
+
nextPlugins = current.flatMap((value, index) => (index === primaryOwned[0] ? [replacement] : primaryOwned.includes(index) ? [] : [value]));
|
|
79
|
+
}
|
|
80
|
+
const writes = [];
|
|
81
|
+
const cleaned = [];
|
|
82
|
+
try {
|
|
83
|
+
if (JSON.stringify(nextPlugins) !== JSON.stringify(current) || primary.config === null) {
|
|
84
|
+
const base = primary.config === null ? { $schema: tuiSchemaUrl } : primary.config;
|
|
85
|
+
writes.push(render(primary, { ...base, plugin: nextPlugins }));
|
|
86
|
+
}
|
|
87
|
+
for (const [index, file] of files.entries()) {
|
|
88
|
+
if (index === primaryIndex || owned[index].length === 0 || file.config === null)
|
|
89
|
+
continue;
|
|
90
|
+
const kept = pluginList(file.config).filter((_value, position) => !owned[index].includes(position));
|
|
91
|
+
writes.push(render(file, { ...file.config, plugin: kept }));
|
|
92
|
+
cleaned.push(file.path);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!(error instanceof OpenCodeConfigCommentError))
|
|
97
|
+
throw error;
|
|
98
|
+
return { report: { ...report(), changed: true, add: [target] }, writes: [], blocked: error.message };
|
|
99
|
+
}
|
|
100
|
+
return { report: report(), writes };
|
|
101
|
+
function report() {
|
|
102
|
+
const changed = writes.length > 0;
|
|
103
|
+
return {
|
|
104
|
+
path: primary.path,
|
|
105
|
+
alreadyPresent,
|
|
106
|
+
changed,
|
|
107
|
+
add: changed ? [target] : [],
|
|
108
|
+
currentCount: current.length,
|
|
109
|
+
resultCount: nextPlugins.length,
|
|
110
|
+
cleaned
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function backup(write) {
|
|
115
|
+
if (write.before === null)
|
|
116
|
+
return undefined;
|
|
117
|
+
const file = write.path + tuiBackupSuffix;
|
|
118
|
+
await writeTextAtomically(file, write.before);
|
|
119
|
+
return file;
|
|
120
|
+
}
|
|
121
|
+
/** Writes a plan: each file that already existed is copied to `<file>.litopencode-backup` first. */
|
|
122
|
+
export async function applyTuiWrites(writes, options = {}) {
|
|
123
|
+
for (const write of writes) {
|
|
124
|
+
if (options.backup !== false)
|
|
125
|
+
await backup(write);
|
|
126
|
+
await fs.mkdir(path.dirname(write.path), { recursive: true });
|
|
127
|
+
await writeTextAtomically(write.path, write.text);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Backs up the TUI files a plan is about to change, for writers outside LitOpenCode such as `opencode plugin`. */
|
|
131
|
+
export async function backupTuiFiles(plan) {
|
|
132
|
+
for (const write of plan.writes)
|
|
133
|
+
await backup(write);
|
|
134
|
+
}
|
|
135
|
+
/** Removes LitOpenCode's own TUI entries, and only those, from tui.json and tui.jsonc. */
|
|
136
|
+
export async function removeTuiPlugin(root, dryRun) {
|
|
137
|
+
const files = await Promise.all(tuiConfigCandidates(root).map(readTuiFile));
|
|
138
|
+
const writes = [];
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
if (file.config === null)
|
|
141
|
+
continue;
|
|
142
|
+
const plugins = pluginList(file.config);
|
|
143
|
+
const owned = await ownIndexes(root, plugins, undefined);
|
|
144
|
+
if (owned.length === 0)
|
|
145
|
+
continue;
|
|
146
|
+
const kept = plugins.filter((_value, index) => !owned.includes(index));
|
|
147
|
+
writes.push({ write: render(file, { ...file.config, plugin: kept }), removed: owned.length });
|
|
148
|
+
}
|
|
149
|
+
const reports = [];
|
|
150
|
+
for (const { write, removed } of writes) {
|
|
151
|
+
if (dryRun) {
|
|
152
|
+
reports.push({ path: write.path, removed });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const backupPath = await backup(write);
|
|
156
|
+
await writeTextAtomically(write.path, write.text);
|
|
157
|
+
reports.push({ path: write.path, removed, ...(backupPath === undefined ? {} : { backup: backupPath }) });
|
|
158
|
+
}
|
|
159
|
+
return { dryRun, removed: reports.reduce((total, entry) => total + entry.removed, 0), files: reports };
|
|
160
|
+
}
|
|
161
|
+
export async function inspectTuiPlugin(root, expected) {
|
|
162
|
+
let files;
|
|
163
|
+
try {
|
|
164
|
+
files = await Promise.all(tuiConfigCandidates(root).map(readTuiFile));
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
return { path: tuiConfigCandidates(root)[0], expected, configured: null, present: false, entries: 0, ok: false, error: message };
|
|
169
|
+
}
|
|
170
|
+
let found;
|
|
171
|
+
let entries = 0;
|
|
172
|
+
let present = false;
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
for (const value of pluginList(file.config)) {
|
|
175
|
+
const id = entryId(value);
|
|
176
|
+
const exact = id === expected || (await isLitOpenCodePathEntry(root, id));
|
|
177
|
+
if (!exact && !isLitOpenCodeEntry(value))
|
|
178
|
+
continue;
|
|
179
|
+
entries += 1;
|
|
180
|
+
if (exact && !present) {
|
|
181
|
+
present = true;
|
|
182
|
+
found = { path: file.path, id };
|
|
183
|
+
}
|
|
184
|
+
else if (found === undefined) {
|
|
185
|
+
found = { path: file.path, id };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const fallback = files.find((file) => file.text !== null)?.path ?? files[0].path;
|
|
190
|
+
return {
|
|
191
|
+
path: found?.path ?? fallback,
|
|
192
|
+
expected,
|
|
193
|
+
configured: found?.id ?? null,
|
|
194
|
+
present,
|
|
195
|
+
entries,
|
|
196
|
+
ok: present && entries === 1
|
|
197
|
+
};
|
|
198
|
+
}
|
package/dist/cli/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OpenCodeHostCapabilities } from "./host-capabilities.ts";
|
|
2
|
+
import type { TuiInstallReport } from "./tui-config.ts";
|
|
2
3
|
export type CliResult = {
|
|
3
4
|
readonly exitCode: number;
|
|
4
5
|
readonly stdout?: string;
|
|
@@ -25,6 +26,7 @@ export type ParsedArgs = {
|
|
|
25
26
|
readonly modelPrompt: ModelPromptMode;
|
|
26
27
|
readonly outputStylePrompt: PermissionPromptMode;
|
|
27
28
|
readonly loop: LoopArgs;
|
|
29
|
+
readonly tuiAction?: string;
|
|
28
30
|
};
|
|
29
31
|
export type LoopArgs = {
|
|
30
32
|
readonly projectRoot: string;
|
|
@@ -181,8 +183,10 @@ export type InstallReport = {
|
|
|
181
183
|
readonly compactionTriggerTokens: number;
|
|
182
184
|
readonly compactionReserveTokens: number;
|
|
183
185
|
};
|
|
186
|
+
readonly tui: TuiInstallReport;
|
|
184
187
|
readonly patch: readonly JsonPatchOperation[];
|
|
185
188
|
readonly changed: boolean;
|
|
189
|
+
readonly blocked?: string;
|
|
186
190
|
readonly routes?: InstallRouteReport;
|
|
187
191
|
readonly capabilities: OpenCodeHostCapabilities;
|
|
188
192
|
readonly package: Pick<PackageMetadata, "name" | "version">;
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { defaultOpenAiRoutes, legacyFastAliasHelp, modelEffortHelp, openaiModelH
|
|
|
10
10
|
import { fetchPublicSource } from "./lit-fetch.js";
|
|
11
11
|
import { readPackageMetadata } from "./cli/json.js";
|
|
12
12
|
import { runMotionRuntime } from "./cli/motion-runtime.js";
|
|
13
|
+
import { removeTuiPlugin } from "./cli/tui-config.js";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { pathToFileURL } from "node:url";
|
|
15
16
|
function helpText() {
|
|
@@ -34,7 +35,8 @@ function helpText() {
|
|
|
34
35
|
" litopencode install [--permission-prompt | --no-permission-prompt] [--permission-mode <safe|balanced|yolo>]",
|
|
35
36
|
" litopencode install|doctor [--no-auto-update]",
|
|
36
37
|
" litopencode doctor [--json] [--root <dir>]",
|
|
37
|
-
" litopencode
|
|
38
|
+
" litopencode tui remove [--dry-run] [--root <dir>]",
|
|
39
|
+
" litopencode motion-runtime install|status [--audio] [--word-timing] [--dry-run] (install) [--json] (status)",
|
|
38
40
|
" litopencode fetch-public <url> [--json] [--timeout <ms>] [--max-bytes <n>]",
|
|
39
41
|
" litopencode fetch-public <local-url> --json --allow-private-network # trusted fixtures only",
|
|
40
42
|
" OpenCode /start-work init|resume|cancel|complete|status <schema-3 JSON>",
|
|
@@ -52,6 +54,7 @@ function helpText() {
|
|
|
52
54
|
"Commands:",
|
|
53
55
|
" install Register litopencode with OpenCode using a branded installer UI.",
|
|
54
56
|
" doctor Report package, config, and runtime path status without writing files.",
|
|
57
|
+
" tui remove Remove only LitOpenCode's TUI panel entry from tui.json/tui.jsonc (backed up first).",
|
|
55
58
|
" fetch-public Fetch a public URL with SSRF guards, verdicts, and trace output.",
|
|
56
59
|
" --allow-private-network is opt-in for reviewed local fixtures or trusted internal tests.",
|
|
57
60
|
"",
|
|
@@ -118,7 +121,7 @@ function helpText() {
|
|
|
118
121
|
export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
119
122
|
if (argv[0] === "motion-runtime") {
|
|
120
123
|
const metadata = await readPackageMetadata();
|
|
121
|
-
return runMotionRuntime(metadata.packageRoot, argv.slice(1));
|
|
124
|
+
return await runMotionRuntime(metadata.packageRoot, argv.slice(1));
|
|
122
125
|
}
|
|
123
126
|
let parsed;
|
|
124
127
|
try {
|
|
@@ -133,9 +136,11 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
133
136
|
return { exitCode: 0, stdout: helpText() };
|
|
134
137
|
}
|
|
135
138
|
if (isLoopCommand(parsed.command))
|
|
136
|
-
return await runLoopCommand(parsed.command, parsed.loop);
|
|
139
|
+
return await runLoopCommand(parsed.command, parsed.loop, parsed.dryRun);
|
|
137
140
|
if (parsed.command === "doctor")
|
|
138
141
|
return await doctor(parsed.root);
|
|
142
|
+
if (parsed.command === "tui")
|
|
143
|
+
return { exitCode: 0, stdout: JSON.stringify(await removeTuiPlugin(parsed.root, parsed.dryRun), null, 2) };
|
|
139
144
|
if (parsed.command === "fetch-public") {
|
|
140
145
|
if (!parsed.fetchPublicUrl)
|
|
141
146
|
return { exitCode: 2, stderr: "fetch-public requires a URL" };
|
|
@@ -2,14 +2,15 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { childNodeExecutable } from "./cli/auto-update.js";
|
|
5
6
|
import { mutatedFilePaths } from "./tool-guards.js";
|
|
6
7
|
const defaultTimeoutMs = 1500;
|
|
7
8
|
const maxTextBytes = 4 * 1024 * 1024;
|
|
8
9
|
const maxOutputBytes = 16 * 1024 * 1024;
|
|
9
10
|
const maxPendingCalls = 512;
|
|
11
|
+
// Prose formats only: in config and data files (YAML, JSON, XML, CSV, TSV) a line such as `source:` is a key.
|
|
10
12
|
const readerFacingExtensions = new Set([
|
|
11
|
-
".adoc", ".
|
|
12
|
-
".xhtml", ".xml", ".json", ".jsonc", ".yaml", ".yml"
|
|
13
|
+
".adoc", ".html", ".htm", ".md", ".mdx", ".org", ".rst", ".svg", ".tex", ".txt", ".xhtml"
|
|
13
14
|
]);
|
|
14
15
|
const officeExtensions = new Set([".docx", ".pptx", ".pdf"]);
|
|
15
16
|
const packageSkillRoot = fileURLToPath(new URL("../skills/lit-humanizer", import.meta.url));
|
|
@@ -108,7 +109,8 @@ function runJsonProcess(command, args, input, timeoutMs) {
|
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
async function defaultScan(request, timeoutMs) {
|
|
111
|
-
|
|
112
|
+
// Inside OpenCode, process.execPath is the Bun host binary, which cannot run the worker script.
|
|
113
|
+
const raw = await runJsonProcess(childNodeExecutable(), [detectorWorker], JSON.stringify(request), timeoutMs);
|
|
112
114
|
const parsed = JSON.parse(raw);
|
|
113
115
|
if (!Array.isArray(parsed.findings))
|
|
114
116
|
throw new Error("detector returned an invalid response");
|
package/dist/hooks.js
CHANGED
|
@@ -7,7 +7,8 @@ export function createToolExecuteBeforeHook(options = {}) {
|
|
|
7
7
|
return async (input, output) => {
|
|
8
8
|
await applyTaskRecursionGuard(input, options.getSession);
|
|
9
9
|
applyLitOpenCodeToolBeforeHook(input, output);
|
|
10
|
-
|
|
10
|
+
// OpenCode sends the tool arguments only in output.args before a call.
|
|
11
|
+
await options.deliverableHedgeGuard?.before({ ...input, args: output.args ?? input.args });
|
|
11
12
|
};
|
|
12
13
|
}
|
|
13
14
|
export function createToolExecuteAfterHook(options = {}) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type MotionFrame } from "./mission-motion.ts";
|
|
2
|
+
import type { MissionView } from "./mission-model.ts";
|
|
3
|
+
import { type Line, type MissionPalette } from "./mission-text.ts";
|
|
4
|
+
export type BandOptions = {
|
|
5
|
+
readonly enabled: boolean;
|
|
6
|
+
readonly card: boolean;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Reads the band switches from the plugin's tui.json options: `band: false` turns the band off,
|
|
10
|
+
* `band: { card: true }` adds the card under the ribbon. Anything else keeps the defaults.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readBandOptions(options: unknown): BandOptions;
|
|
13
|
+
/**
|
|
14
|
+
* One ribbon row exactly `width` cells wide: pills, bar, the next task or a warning, and the elapsed
|
|
15
|
+
* time, shedding parts in a fixed order so it never wraps. Idle sessions draw nothing.
|
|
16
|
+
*/
|
|
17
|
+
export declare function bandRibbon(view: MissionView, frame: MotionFrame, palette: MissionPalette, width: number, place: {
|
|
18
|
+
readonly inLane: boolean;
|
|
19
|
+
}): Line;
|
|
20
|
+
export type BandCard = {
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly border: string;
|
|
23
|
+
readonly rows: readonly Line[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The opt-in card under the ribbon. It keeps a fixed four rows whenever a goal exists, warnings
|
|
27
|
+
* first, so turning a warning on or off never moves the prompt.
|
|
28
|
+
*/
|
|
29
|
+
export declare function bandCard(view: MissionView, frame: MotionFrame, palette: MissionPalette, width: number): BandCard | undefined;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The band: one full-width ribbon row in OpenCode's app_bottom slot that carries the lit mission on
|
|
2
|
+
// the main screen, plus an opt-in card. It reuses the chip's pills, the panel's bar and the shared
|
|
3
|
+
// motion frame, so the chip, the panel and the band always move together.
|
|
4
|
+
import { mixHex, motionTiming } from "./mission-motion.js";
|
|
5
|
+
import { cellWidth, chipGroups, chipSparkCells, chipSparkWidth, groupsWidth, lineWidth, missionPanel, progressBar, shimmerAt, statusSegments, truncateCells } from "./mission-text.js";
|
|
6
|
+
/**
|
|
7
|
+
* Reads the band switches from the plugin's tui.json options: `band: false` turns the band off,
|
|
8
|
+
* `band: { card: true }` adds the card under the ribbon. Anything else keeps the defaults.
|
|
9
|
+
*/
|
|
10
|
+
export function readBandOptions(options) {
|
|
11
|
+
const band = typeof options === "object" && options !== null ? options.band : undefined;
|
|
12
|
+
if (band === false)
|
|
13
|
+
return { enabled: false, card: false };
|
|
14
|
+
const card = typeof band === "object" && band !== null && band.card === true;
|
|
15
|
+
return { enabled: true, card };
|
|
16
|
+
}
|
|
17
|
+
// Shedding order as the row narrows: step labels first, then the next-task text, then bar width,
|
|
18
|
+
// then the time. Each plan is tried in turn and the first that fits wins.
|
|
19
|
+
const plans = [
|
|
20
|
+
{ tier: "full", bar: 18, text: true, time: true },
|
|
21
|
+
{ tier: "medium", bar: 18, text: true, time: true },
|
|
22
|
+
{ tier: "tight", bar: 18, text: true, time: true },
|
|
23
|
+
{ tier: "tight", bar: 18, text: false, time: true },
|
|
24
|
+
{ tier: "tight", bar: 10, text: false, time: true },
|
|
25
|
+
{ tier: "tight", bar: 5, text: false, time: true },
|
|
26
|
+
{ tier: "tight", bar: 0, text: false, time: true },
|
|
27
|
+
{ tier: "tight", bar: 0, text: false, time: false }
|
|
28
|
+
];
|
|
29
|
+
const minimumText = 12;
|
|
30
|
+
function elapsedText(minutes) {
|
|
31
|
+
const total = Math.max(0, minutes ?? 0);
|
|
32
|
+
return total < 60 ? `${total}m` : `${Math.floor(total / 60)}h${String(total % 60).padStart(2, "0")}`;
|
|
33
|
+
}
|
|
34
|
+
/** The text after the bar: a warning (⚑ and words) replaces the next task; a finished mission shows COMPLETE. */
|
|
35
|
+
function trailing(view, frame, palette, room) {
|
|
36
|
+
if (view.complete) {
|
|
37
|
+
// Before COMPLETE pops in, the slot stays empty rather than flashing the header's "● active".
|
|
38
|
+
if (frame.celebrate !== undefined && frame.celebrate < motionTiming.popAt)
|
|
39
|
+
return [];
|
|
40
|
+
const status = statusSegments(view, frame, palette);
|
|
41
|
+
const lead = Math.max(1, 2 + status.offset);
|
|
42
|
+
return [{ text: " ".repeat(lead), fg: palette.muted }, ...status.segments];
|
|
43
|
+
}
|
|
44
|
+
const warning = view.warnings[0];
|
|
45
|
+
if (warning !== undefined) {
|
|
46
|
+
const color = warning.kind === "retry" ? mixHex(palette.warning, palette.error, 0.5) : palette.warning;
|
|
47
|
+
return [{ text: " ⚑ ", fg: color, bold: true }, { text: truncateCells(warning.text, room - 4), fg: color }];
|
|
48
|
+
}
|
|
49
|
+
if (view.next === undefined)
|
|
50
|
+
return [];
|
|
51
|
+
return [{ text: " ▸ ", fg: palette.muted }, { text: truncateCells(view.next, room - 4), fg: palette.text }];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One ribbon row exactly `width` cells wide: pills, bar, the next task or a warning, and the elapsed
|
|
55
|
+
* time, shedding parts in a fixed order so it never wraps. Idle sessions draw nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function bandRibbon(view, frame, palette, width, place) {
|
|
58
|
+
if (!view.active || width <= 0)
|
|
59
|
+
return [];
|
|
60
|
+
const time = { text: `⟳ ${elapsedText(view.elapsedMinutes)}${place.inLane ? " · in lane" : ""}`, fg: palette.muted };
|
|
61
|
+
const sweep = shimmerAt(frame);
|
|
62
|
+
for (const plan of plans) {
|
|
63
|
+
const pills = chipGroups(view, frame, palette, plan.tier, false).flat();
|
|
64
|
+
const bar = plan.bar > 0 && view.progress !== undefined ? [{ text: " ", fg: palette.muted }, ...progressBar(frame.barRatio, plan.bar, palette, sweep)] : [];
|
|
65
|
+
const timeWidth = plan.time ? cellWidth(time.text) + 1 : 0;
|
|
66
|
+
const fixed = lineWidth(pills) + lineWidth(bar) + timeWidth;
|
|
67
|
+
if (fixed > width)
|
|
68
|
+
continue;
|
|
69
|
+
const room = width - fixed;
|
|
70
|
+
const after = trailing(view, frame, palette, room);
|
|
71
|
+
const keepText = plan.text && after.length > 0 && (view.complete ? lineWidth(after) <= room : room >= minimumText);
|
|
72
|
+
if (plan.text && after.length > 0 && !keepText)
|
|
73
|
+
continue;
|
|
74
|
+
const body = [...pills, ...bar, ...(keepText ? after : [])];
|
|
75
|
+
let used = lineWidth(body) + timeWidth;
|
|
76
|
+
const sparks = frame.celebrate !== undefined && used + chipSparkWidth <= width ? chipSparkCells(frame.celebrate, palette) : undefined;
|
|
77
|
+
if (sparks !== undefined)
|
|
78
|
+
used += chipSparkWidth;
|
|
79
|
+
const gap = width - used + (plan.time ? 1 : 0);
|
|
80
|
+
// Sparks sit after the text, in the free space, so the pills never shift while they play.
|
|
81
|
+
return [...body, ...(sparks ?? []), { text: " ".repeat(Math.max(plan.time ? 1 : 0, gap)), fg: palette.muted }, ...(plan.time ? [time] : [])];
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const cardRows = 4;
|
|
86
|
+
/**
|
|
87
|
+
* The opt-in card under the ribbon. It keeps a fixed four rows whenever a goal exists, warnings
|
|
88
|
+
* first, so turning a warning on or off never moves the prompt.
|
|
89
|
+
*/
|
|
90
|
+
export function bandCard(view, frame, palette, width) {
|
|
91
|
+
if (!view.active || (view.goal === undefined && view.warnings.length === 0))
|
|
92
|
+
return undefined;
|
|
93
|
+
const panel = missionPanel(view, frame, palette);
|
|
94
|
+
if (panel === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
const inner = Math.max(8, width - 4);
|
|
97
|
+
const rows = [];
|
|
98
|
+
for (const warning of view.warnings.slice(0, 2)) {
|
|
99
|
+
rows.push([{ text: "⚑ ", fg: palette.warning, bold: true }, { text: truncateCells(warning.text, inner - 2), fg: palette.warning }]);
|
|
100
|
+
}
|
|
101
|
+
if (view.next !== undefined && !view.complete)
|
|
102
|
+
rows.push([{ text: "next ", fg: palette.muted }, { text: truncateCells(`▸ ${view.next}`, inner - 6), fg: palette.text }]);
|
|
103
|
+
if (view.goal !== undefined) {
|
|
104
|
+
const criteria = view.goal.criteria.map((criterion) => `${criterion.status === "pass" ? "✓" : criterion.status === "fail" ? "✗" : "○"}${criterion.id}`).join(" ");
|
|
105
|
+
const title = truncateCells(view.goal.title, Math.max(4, inner - 8 - cellWidth(criteria)));
|
|
106
|
+
rows.push([
|
|
107
|
+
{ text: "goal ", fg: palette.muted },
|
|
108
|
+
{ text: title, fg: palette.text, bold: true },
|
|
109
|
+
{ text: " ", fg: palette.muted },
|
|
110
|
+
{ text: truncateCells(criteria, Math.max(0, inner - 8 - cellWidth(title))), fg: palette.muted }
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
while (rows.length < cardRows)
|
|
114
|
+
rows.push([]);
|
|
115
|
+
return { title: panel.title, border: panel.border, rows: rows.slice(0, cardRows) };
|
|
116
|
+
}
|