diff --git a/packages/app/src/components/SkillsSidebarSection.tsx b/packages/app/src/components/SkillsSidebarSection.tsx index 4f6649ea..8824b09c 100644 --- a/packages/app/src/components/SkillsSidebarSection.tsx +++ b/packages/app/src/components/SkillsSidebarSection.tsx @@ -11,6 +11,7 @@ import { RefreshCw, } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { NewSkillDialog } from '@/components/NewSkillDialog'; import { SkillStateBadge } from '@/components/SkillStateBadge'; import { @@ -116,6 +117,9 @@ export function SkillsSidebarSection() { + s.scope === 'project' && !s.managed)} + /> {skills.length === 0 ? (

No skills yet. @@ -498,6 +502,114 @@ function FileTreeNode({ ); } +/** + * Local-only skills-are-hidden prompt. In local-only mode the whole `.ok/` + * tree is git-ignored, so `.ok/skills/` (shareable content) can't be committed + * — the "skills silently can't be shared" trap. When project skills exist in + * that state, offer a one-click carve-out that makes JUST `.ok/skills/` + * shareable while the rest of `.ok/` stays local (via `sharing.setSkillsShared`). + * Desktop-only (the sharing bridge is absent on the web host). + */ +function SkillsShareLocalOnlyPrompt({ hasProjectSkills }: { hasProjectSkills: boolean }) { + const { t } = useLingui(); + // `localOnlyHidden` = local-only AND skills not yet carved out (the broken + // state this prompt exists to fix). null = not yet read / not applicable. + const [localOnlyHidden, setLocalOnlyHidden] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + // useEffect runs client-side, so `window` is defined (oxlint no-restricted-syntax). + const bridge = window.okDesktop?.sharing; + if (!bridge) return; + let cancelled = false; + const read = () => { + void bridge + .status() + .then((s) => { + if (!cancelled) setLocalOnlyHidden(s.mode === 'local-only' && !s.skillsShared); + }) + .catch((err) => { + // Advisory read — degrade to hiding the prompt, but keep a signal so an + // IPC regression isn't invisible (packaged desktop has no user console). + console.warn( + JSON.stringify({ + event: 'skills-share-status-read-failed', + error: err instanceof Error ? err.message : String(err), + }), + ); + }); + }; + read(); + // Re-read on window refocus so the banner tracks a sharing-mode change made + // in Settings while the sidebar stayed mounted (closing that dialog refocuses + // the window). Without this the mount-time read goes stale. + window.addEventListener('focus', read); + return () => { + cancelled = true; + window.removeEventListener('focus', read); + }; + }, []); + + if (!hasProjectSkills || localOnlyHidden !== true) return null; + + // No try/finally: this repo's React Compiler can't lower a finalizer clause, + // so capture the result/error and settle `busy` after the await (mirrors + // SharingSection's handler). + const share = async () => { + const bridge = window.okDesktop?.sharing; + if (!bridge || busy) return; + setBusy(true); + let result: Awaited> | null = null; + let err: unknown = null; + try { + result = await bridge.setSkillsShared(true); + } catch (caught) { + err = caught; + } + setBusy(false); + if (err !== null) { + // A wire-protocol violation surfaces as `ok:sharing:dispatch: ...` from the + // preload — meaningless to end users, so show a generic message instead. + const raw = err instanceof Error ? err.message : ''; + toast.error( + raw.startsWith('ok:sharing:dispatch:') + ? t`Couldn't share skills: internal error. Please restart the app.` + : raw || t`Couldn't share skills`, + ); + return; + } + if (result === null) return; + if (result.kind === 'applied') { + toast.success(t`Skills are now shared. Commit .ok/skills to share them with your team.`); + setLocalOnlyHidden(false); + } else if (result.kind === 'no-exclude') { + // Map internal reason codes to plain English (mirrors SharingSection). + const detail = + result.reason === 'no-git' + ? t`no git repository here` + : result.reason === 'inaccessible' + ? t`the git exclude file isn't writable` + : t`git configuration is unavailable`; + toast.warning(t`Couldn't share skills — ${detail}.`); + } + }; + + return ( +

+

+ + This project is local-only, so .ok/skills is hidden from git and these skills can't be + committed or shared. Share just the skills folder — the rest of your OK config stays on + this computer. + +

+ +
+ ); +} + /** * One-time import offer: when this project is undecided about skill management * AND there are editor-dir skills OK could adopt, ask once. "Import" flips the diff --git a/packages/app/src/components/settings/SharingSection.tsx b/packages/app/src/components/settings/SharingSection.tsx index b2405464..d702c7aa 100644 --- a/packages/app/src/components/settings/SharingSection.tsx +++ b/packages/app/src/components/settings/SharingSection.tsx @@ -76,7 +76,14 @@ function SharingSectionBody() { // renders (the toast already surfaced the real error). A later refresh // failure keeps the last good status rather than clobbering it. setStatus( - (prev) => prev ?? { kind: 'status', mode: 'no-git', excluded: [], trackedUpstream: [] }, + (prev) => + prev ?? { + kind: 'status', + mode: 'no-git', + excluded: [], + trackedUpstream: [], + skillsShared: false, + }, ); } } @@ -130,6 +137,43 @@ function SharingSectionBody() { await refresh(); } + // Undo the local-only skills carve-out (`.ok/skills` back to hidden). Only + // offered when the project is in the carve state; mirrors onSelect's + // capture-error-after-await shape (no try/finally — React Compiler can't + // lower a finalizer). + async function onUndoSkillsShare() { + const bridge = window.okDesktop?.sharing; + if (!bridge || status === null || busy) return; + setBusy(true); + let result: Awaited> | null = null; + let err: unknown = null; + try { + result = await bridge.setSkillsShared(false); + } catch (caught) { + err = caught; + } + setBusy(false); + if (err !== null) { + toast.error(err instanceof Error ? err.message : t`Couldn't update skills sharing`); + return; + } + if (result === null) return; + if (result.kind === 'no-exclude') { + // Inspect the result like onSelect does — a `no-exclude` outcome (git + // config unavailable / exclude file unwritable) is a failure, not success. + const detail = + result.reason === 'no-git' + ? t`no git repository here` + : result.reason === 'inaccessible' + ? t`the git exclude file isn't writable` + : t`git configuration is unavailable`; + toast.warning(t`Couldn't update skills sharing — ${detail}.`); + return; + } + toast.success(t`.ok/skills is back to local-only.`); + await refresh(); + } + if (status === null) { return (
@@ -215,6 +259,33 @@ function SharingSectionBody() { )} + {status.mode === 'local-only' && status.skillsShared ? ( +
+ + + Exception: skills are shared + + + + .ok/skills is committable so your team gets your skills; the rest of .ok/ stays on + this computer. + + + + +
+ ) : null} + {refusal !== null ? (
; setMode(mode: 'shared' | 'local-only'): Promise; + /** Toggle `.ok/skills/` shareability within local-only mode. */ + setSkillsShared(shared: boolean): Promise; }; /** diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index bb710461..1cf5511e 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -234,6 +234,7 @@ "4buZWd": ["Inherited from ", ["path"]], "4c_a6S": ["Nothing set yet."], "4g19V6": ["Failed to update property"], + "4h3e0H": ["Couldn't update skills sharing — ", ["detail"], "."], "4hJhzz": ["Table"], "4ieVh2": ["Pick “Custom” above to use this palette."], "4jDd0j": ["Anyone can see"], @@ -474,6 +475,7 @@ "9oE4C3": ["Browse your repos:"], "9sBe85": ["Follow us on"], "9tJX8r": ["By meaning"], + "9uI_rE": ["Undo"], "9xCnv8": ["Hide Terminal"], "9zb2WA": ["Connecting"], "A1taO8": ["Search"], @@ -552,6 +554,9 @@ "Bdha5y": ["Publishing..."], "BeWTQy": ["Loading starter packs"], "BgV29F": ["Visual editor find input focused"], + "Bh9671": [ + "This project is local-only, so .ok/skills is hidden from git and these skills can't be committed or shared. Share just the skills folder — the rest of your OK config stays on this computer." + ], "Bn9-El": ["default"], "Bv5a9_": ["Selection statistics"], "Bx4EXG": ["Actions for ", ["label"]], @@ -655,6 +660,7 @@ ], "E7BhZn": ["Blank note"], "E9bKSA": ["Creating"], + "EARn6h": ["Couldn't share skills — ", ["detail"], "."], "EBVbtP": ["Could not duplicate item"], "EDl9kS": ["Subscribe"], "EDm3wJ": ["Loading diff renderer"], @@ -902,8 +908,12 @@ "Jw6EC4": ["Branch switch failed — try opening the ", ["targetNoun"], " manually."], "K-a33w": ["Start writing to create this page"], "K-h9cT": ["Couldn't save the resolution for ", ["filePath"], "."], + "K0FgsP": [ + ".ok/skills is committable so your team gets your skills; the rest of .ok/ stays on this computer." + ], "K0N7zS": ["Config sharing status read failed"], "K1ckLX": ["Failed to create folder"], + "K2v7QF": ["Couldn't share skills"], "K6MQQk": ["Sync is failing. See the sync status for details."], "K6jjWB": [["packName"], " was already set up. Nothing to do."], "K6jxcj": ["Add a key"], @@ -939,6 +949,7 @@ "> isn't a known component. Copy the source to use it elsewhere, or delete the block." ], "Khxcyu": ["Terminal access isn't enabled for this project."], + "Kiytqd": ["Exception: skills are shared"], "KkEAud": ["Create a file from the current document or folder context."], "KmydK6": ["Bold"], "KnoB--": ["Will be saved as <0>", ["sanitized"], "."], @@ -1180,6 +1191,7 @@ "PiH3UR": ["Copied!"], "PkeYXL": ["Expanded graph mode"], "Pkw7J9": ["This folder is empty."], + "PlGC6r": ["the git exclude file isn't writable"], "PqYWiC": [ "Couldn't restart the server automatically. Try running `ok stop all` in a terminal, then reopen this project — or restart your computer if it persists." ], @@ -1190,6 +1202,7 @@ "Q5fqMz": ["Connection error — try again"], "Q6hhn8": ["Preferences"], "Q7W-mC": ["Same folder as current file"], + "Q7cNKe": ["Skills are now shared. Commit .ok/skills to share them with your team."], "Q8A-U_": ["Connected to"], "QAi7Q9": [ "Branch <0>", @@ -1305,6 +1318,7 @@ "TJnkyf": ["ok ui responded but server.lock has no port yet"], "TKQ7K-": ["Install"], "TLIO3j": ["Toggle italic formatting."], + "TUzThQ": ["Share skills"], "T_R-Qz": ["Primary"], "T_wG4n": ["We couldn't check your GitHub connection."], "Ta25TG": ["No history yet"], @@ -1504,6 +1518,7 @@ "XvPJaT": ["Diff layout"], "Xv_ijG": ["https://github.com/owner/repo or owner/repo"], "Xw_maT": ["Type a query, then press ↵ to search your pages by meaning."], + "Xwump4": ["no git repository here"], "XyOUTJ": ["Exit merge"], "Xzy0s0": ["Remove ", ["fullLabel"], " from context"], "Y-Urt2": ["Block math equation rendered with KaTeX from a LaTeX source string."], @@ -2310,6 +2325,7 @@ "ogAuyr": ["Back to document view"], "ohUJJM": ["Plugins"], "omXJcU": ["Close others"], + "onTlRk": ["Couldn't update skills sharing"], "optX0N": [["0"], "h ago"], "oqB2ez": ["Previous match"], "osRqlv": ["Open delete confirmation for the selected file-tree items."], @@ -2329,6 +2345,7 @@ "pEQFsW": ["No matching projects."], "pFagOn": ["Source editing"], "pHYP6v": ["Open <0>Customize in the sidebar <1/> <2>Skills."], + "pQf2Ux": [".ok/skills is back to local-only."], "pRc-Im": ["Undone all edits on ", ["docName"]], "pTPqMK": ["Toggle a fenced code block."], "pWT04I": ["Checking..."], @@ -2372,6 +2389,7 @@ "qSKu2v": ["Source navigation and selection"], "qSxaOz": ["Accept their deletion"], "qUB_o_": ["Auto-sync is off — your edits stay local until you commit and push manually."], + "qXN416": ["Couldn't share skills: internal error. Please restart the app."], "qXg5W9": ["File saves to <0>~/Downloads/openknowledge.skill."], "qXsafh": ["Publish to GitHub"], "qZv5ZR": ["Config sharing toggle failed"], @@ -2762,6 +2780,7 @@ "The properties block at the top of this doc has a formatting error. Switch to source mode to fix it." ], "zbymaY": [["0"], "m ago"], + "zcmYx4": ["git configuration is unavailable"], "zd00pH": ["Copy Path"], "zgSFyG": ["External link"], "zkz0Fg": ["Set up syncing"], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 62f3f255..0a52377f 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -29,6 +29,14 @@ msgstr " Your selection has been restored." msgid ".ok folders" msgstr ".ok folders" +#: src/components/settings/SharingSection.tsx +msgid ".ok/skills is back to local-only." +msgstr ".ok/skills is back to local-only." + +#: src/components/settings/SharingSection.tsx +msgid ".ok/skills is committable so your team gets your skills; the rest of .ok/ stays on this computer." +msgstr ".ok/skills is committable so your team gets your skills; the rest of .ok/ stays on this computer." + #. placeholder {0}: skill.name #. placeholder {1}: label(result.hosts) #: src/components/skill-actions.tsx @@ -2322,6 +2330,18 @@ msgstr "Couldn't send the selection — please try again." msgid "Couldn't send your prompt — please try again." msgstr "Couldn't send your prompt — please try again." +#: src/components/SkillsSidebarSection.tsx +msgid "Couldn't share skills" +msgstr "Couldn't share skills" + +#: src/components/SkillsSidebarSection.tsx +msgid "Couldn't share skills — {detail}." +msgstr "Couldn't share skills — {detail}." + +#: src/components/SkillsSidebarSection.tsx +msgid "Couldn't share skills: internal error. Please restart the app." +msgstr "Couldn't share skills: internal error. Please restart the app." + #. placeholder {0}: result.error #: src/components/skill-actions.tsx msgid "Couldn't uninstall skill: {0}" @@ -2337,6 +2357,14 @@ msgstr "Couldn't update skill targets: {0}" msgid "Couldn't update skill: {0}" msgstr "Couldn't update skill: {0}" +#: src/components/settings/SharingSection.tsx +msgid "Couldn't update skills sharing" +msgstr "Couldn't update skills sharing" + +#: src/components/settings/SharingSection.tsx +msgid "Couldn't update skills sharing — {detail}." +msgstr "Couldn't update skills sharing — {detail}." + #: src/components/ConsentDialogBody.tsx msgid "Counting markdown files" msgstr "Counting markdown files" @@ -3150,6 +3178,10 @@ msgstr "Entry" msgid "Error:" msgstr "Error:" +#: src/components/settings/SharingSection.tsx +msgid "Exception: skills are shared" +msgstr "Exception: skills are shared" + #: src/components/NewWorktreeDialog.tsx msgid "Existing branch <0>{trimmed} will be checked out into its own window, under <1>.ok/worktrees/." msgstr "Existing branch <0>{trimmed} will be checked out into its own window, under <1>.ok/worktrees/." @@ -3729,6 +3761,11 @@ msgstr "Get set up" msgid "Git auto-sync" msgstr "Git auto-sync" +#: src/components/settings/SharingSection.tsx +#: src/components/SkillsSidebarSection.tsx +msgid "git configuration is unavailable" +msgstr "git configuration is unavailable" + #: src/components/SyncStatusBadge.tsx msgid "Git identity isn't set — commits use a default author. Set yours so teammates see your name." msgstr "Git identity isn't set — commits use a default author. Set yours so teammates see your name." @@ -5039,6 +5076,11 @@ msgstr "No git remote" msgid "No git repository — config sharing does not apply here." msgstr "No git repository — config sharing does not apply here." +#: src/components/settings/SharingSection.tsx +#: src/components/SkillsSidebarSection.tsx +msgid "no git repository here" +msgstr "no git repository here" + #: src/components/settings/SkillsManagerSection.tsx msgid "No global skills yet." msgstr "No global skills yet." @@ -7002,6 +7044,10 @@ msgstr "Share doc" msgid "Share folder" msgstr "Share folder" +#: src/components/SkillsSidebarSection.tsx +msgid "Share skills" +msgstr "Share skills" + #: src/components/SharingModeField.tsx msgid "Share this setup with your team?" msgstr "Share this setup with your team?" @@ -7231,6 +7277,10 @@ msgstr "Skill renamed" msgid "Skills" msgstr "Skills" +#: src/components/SkillsSidebarSection.tsx +msgid "Skills are now shared. Commit .ok/skills to share them with your team." +msgstr "Skills are now shared. Commit .ok/skills to share them with your team." + #: src/components/settings/SkillsManagerSection.tsx msgid "Skills teach agents repeatable tasks. Author them here; install a skill to project it into your editors' skill folders." msgstr "Skills teach agents repeatable tasks. Author them here; install a skill to project it into your editors' skill folders." @@ -7776,6 +7826,11 @@ msgstr "The folders and templates are already here, so there's nothing to add." msgid "The formula <0>c = √(a² + b²) renders inline." msgstr "The formula <0>c = √(a² + b²) renders inline." +#: src/components/settings/SharingSection.tsx +#: src/components/SkillsSidebarSection.tsx +msgid "the git exclude file isn't writable" +msgstr "the git exclude file isn't writable" + #: src/components/TemplateProperties.tsx msgid "The menu label agents pick this template by (required)." msgstr "The menu label agents pick this template by (required)." @@ -7988,6 +8043,10 @@ msgstr "This project" msgid "This project has no git repository — sharing mode does not apply. Initialize a repo to enable the toggle." msgstr "This project has no git repository — sharing mode does not apply. Initialize a repo to enable the toggle." +#: src/components/SkillsSidebarSection.tsx +msgid "This project is local-only, so .ok/skills is hidden from git and these skills can't be committed or shared. Share just the skills folder — the rest of your OK config stays on this computer." +msgstr "This project is local-only, so .ok/skills is hidden from git and these skills can't be committed or shared. Share just the skills folder — the rest of your OK config stays on this computer." + #. placeholder {0}: info.appRuntime #: src/lib/install-server-drift-listener.ts msgid "This project is running a different, incompatible build of OpenKnowledge than this app (v{0})." @@ -8225,6 +8284,10 @@ msgstr "Uncommitted changes" msgid "Underline" msgstr "Underline" +#: src/components/settings/SharingSection.tsx +msgid "Undo" +msgstr "Undo" + #: src/components/ActivityPanelFileRow.tsx msgid "Undo all" msgstr "Undo all" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 6ee04960..6bf38f40 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -234,6 +234,7 @@ "4buZWd": ["Ĩńĥēŕĩţēď ƒŕōḿ ", ["path"]], "4c_a6S": ["Ńōţĥĩńĝ śēţ ŷēţ."], "4g19V6": ["Ƒàĩĺēď ţō ũƥďàţē ƥŕōƥēŕţŷ"], + "4h3e0H": ["Ćōũĺďń'ţ ũƥďàţē śķĩĺĺś śĥàŕĩńĝ — ", ["detail"], "."], "4hJhzz": ["Ţàƀĺē"], "4ieVh2": ["Ƥĩćķ “Ćũśţōḿ” àƀōvē ţō ũśē ţĥĩś ƥàĺēţţē."], "4jDd0j": ["Àńŷōńē ćàń śēē"], @@ -474,6 +475,7 @@ "9oE4C3": ["ßŕōŵśē ŷōũŕ ŕēƥōś:"], "9sBe85": ["Ƒōĺĺōŵ ũś ōń"], "9tJX8r": ["ßŷ ḿēàńĩńĝ"], + "9uI_rE": ["Ũńďō"], "9xCnv8": ["Ĥĩďē Ţēŕḿĩńàĺ"], "9zb2WA": ["Ćōńńēćţĩńĝ"], "A1taO8": ["Śēàŕćĥ"], @@ -552,6 +554,9 @@ "Bdha5y": ["Ƥũƀĺĩśĥĩńĝ..."], "BeWTQy": ["Ĺōàďĩńĝ śţàŕţēŕ ƥàćķś"], "BgV29F": ["Vĩśũàĺ ēďĩţōŕ ƒĩńď ĩńƥũţ ƒōćũśēď"], + "Bh9671": [ + "Ţĥĩś ƥŕōĴēćţ ĩś ĺōćàĺ-ōńĺŷ, śō .ōķ/śķĩĺĺś ĩś ĥĩďďēń ƒŕōḿ ĝĩţ àńď ţĥēśē śķĩĺĺś ćàń'ţ ƀē ćōḿḿĩţţēď ōŕ śĥàŕēď. Śĥàŕē Ĵũśţ ţĥē śķĩĺĺś ƒōĺďēŕ — ţĥē ŕēśţ ōƒ ŷōũŕ ŌĶ ćōńƒĩĝ śţàŷś ōń ţĥĩś ćōḿƥũţēŕ." + ], "Bn9-El": ["ďēƒàũĺţ"], "Bv5a9_": ["Śēĺēćţĩōń śţàţĩśţĩćś"], "Bx4EXG": ["Àćţĩōńś ƒōŕ ", ["label"]], @@ -655,6 +660,7 @@ ], "E7BhZn": ["ßĺàńķ ńōţē"], "E9bKSA": ["Ćŕēàţĩńĝ"], + "EARn6h": ["Ćōũĺďń'ţ śĥàŕē śķĩĺĺś — ", ["detail"], "."], "EBVbtP": ["Ćōũĺď ńōţ ďũƥĺĩćàţē ĩţēḿ"], "EDl9kS": ["Śũƀśćŕĩƀē"], "EDm3wJ": ["Ĺōàďĩńĝ ďĩƒƒ ŕēńďēŕēŕ"], @@ -902,8 +908,12 @@ "Jw6EC4": ["ßŕàńćĥ śŵĩţćĥ ƒàĩĺēď — ţŕŷ ōƥēńĩńĝ ţĥē ", ["targetNoun"], " ḿàńũàĺĺŷ."], "K-a33w": ["Śţàŕţ ŵŕĩţĩńĝ ţō ćŕēàţē ţĥĩś ƥàĝē"], "K-h9cT": ["Ćōũĺďń'ţ śàvē ţĥē ŕēśōĺũţĩōń ƒōŕ ", ["filePath"], "."], + "K0FgsP": [ + ".ōķ/śķĩĺĺś ĩś ćōḿḿĩţţàƀĺē śō ŷōũŕ ţēàḿ ĝēţś ŷōũŕ śķĩĺĺś; ţĥē ŕēśţ ōƒ .ōķ/ śţàŷś ōń ţĥĩś ćōḿƥũţēŕ." + ], "K0N7zS": ["Ćōńƒĩĝ śĥàŕĩńĝ śţàţũś ŕēàď ƒàĩĺēď"], "K1ckLX": ["Ƒàĩĺēď ţō ćŕēàţē ƒōĺďēŕ"], + "K2v7QF": ["Ćōũĺďń'ţ śĥàŕē śķĩĺĺś"], "K6MQQk": ["Śŷńć ĩś ƒàĩĺĩńĝ. Śēē ţĥē śŷńć śţàţũś ƒōŕ ďēţàĩĺś."], "K6jjWB": [["packName"], " ŵàś àĺŕēàďŷ śēţ ũƥ. Ńōţĥĩńĝ ţō ďō."], "K6jxcj": ["Àďď à ķēŷ"], @@ -939,6 +949,7 @@ "> ĩśń'ţ à ķńōŵń ćōḿƥōńēńţ. Ćōƥŷ ţĥē śōũŕćē ţō ũśē ĩţ ēĺśēŵĥēŕē, ōŕ ďēĺēţē ţĥē ƀĺōćķ." ], "Khxcyu": ["Ţēŕḿĩńàĺ àććēśś ĩśń'ţ ēńàƀĺēď ƒōŕ ţĥĩś ƥŕōĴēćţ."], + "Kiytqd": ["Ēxćēƥţĩōń: śķĩĺĺś àŕē śĥàŕēď"], "KkEAud": ["Ćŕēàţē à ƒĩĺē ƒŕōḿ ţĥē ćũŕŕēńţ ďōćũḿēńţ ōŕ ƒōĺďēŕ ćōńţēxţ."], "KmydK6": ["ßōĺď"], "KnoB--": ["Ŵĩĺĺ ƀē śàvēď àś <0>", ["sanitized"], "."], @@ -1180,6 +1191,7 @@ "PiH3UR": ["Ćōƥĩēď!"], "PkeYXL": ["Ēxƥàńďēď ĝŕàƥĥ ḿōďē"], "Pkw7J9": ["Ţĥĩś ƒōĺďēŕ ĩś ēḿƥţŷ."], + "PlGC6r": ["ţĥē ĝĩţ ēxćĺũďē ƒĩĺē ĩśń'ţ ŵŕĩţàƀĺē"], "PqYWiC": [ "Ćōũĺďń'ţ ŕēśţàŕţ ţĥē śēŕvēŕ àũţōḿàţĩćàĺĺŷ. Ţŕŷ ŕũńńĩńĝ `ōķ śţōƥ àĺĺ` ĩń à ţēŕḿĩńàĺ, ţĥēń ŕēōƥēń ţĥĩś ƥŕōĴēćţ — ōŕ ŕēśţàŕţ ŷōũŕ ćōḿƥũţēŕ ĩƒ ĩţ ƥēŕśĩśţś." ], @@ -1190,6 +1202,7 @@ "Q5fqMz": ["Ćōńńēćţĩōń ēŕŕōŕ — ţŕŷ àĝàĩń"], "Q6hhn8": ["Ƥŕēƒēŕēńćēś"], "Q7W-mC": ["Śàḿē ƒōĺďēŕ àś ćũŕŕēńţ ƒĩĺē"], + "Q7cNKe": ["Śķĩĺĺś àŕē ńōŵ śĥàŕēď. Ćōḿḿĩţ .ōķ/śķĩĺĺś ţō śĥàŕē ţĥēḿ ŵĩţĥ ŷōũŕ ţēàḿ."], "Q8A-U_": ["Ćōńńēćţēď ţō"], "QAi7Q9": [ "ßŕàńćĥ <0>", @@ -1305,6 +1318,7 @@ "TJnkyf": ["ōķ ũĩ ŕēśƥōńďēď ƀũţ śēŕvēŕ.ĺōćķ ĥàś ńō ƥōŕţ ŷēţ"], "TKQ7K-": ["Ĩńśţàĺĺ"], "TLIO3j": ["Ţōĝĝĺē ĩţàĺĩć ƒōŕḿàţţĩńĝ."], + "TUzThQ": ["Śĥàŕē śķĩĺĺś"], "T_R-Qz": ["Ƥŕĩḿàŕŷ"], "T_wG4n": ["Ŵē ćōũĺďń'ţ ćĥēćķ ŷōũŕ ĜĩţĤũƀ ćōńńēćţĩōń."], "Ta25TG": ["Ńō ĥĩśţōŕŷ ŷēţ"], @@ -1504,6 +1518,7 @@ "XvPJaT": ["Ďĩƒƒ ĺàŷōũţ"], "Xv_ijG": ["ĥţţƥś://ĝĩţĥũƀ.ćōḿ/ōŵńēŕ/ŕēƥō ōŕ ōŵńēŕ/ŕēƥō"], "Xw_maT": ["Ţŷƥē à ǫũēŕŷ, ţĥēń ƥŕēśś ↵ ţō śēàŕćĥ ŷōũŕ ƥàĝēś ƀŷ ḿēàńĩńĝ."], + "Xwump4": ["ńō ĝĩţ ŕēƥōśĩţōŕŷ ĥēŕē"], "XyOUTJ": ["Ēxĩţ ḿēŕĝē"], "Xzy0s0": ["Ŕēḿōvē ", ["fullLabel"], " ƒŕōḿ ćōńţēxţ"], "Y-Urt2": ["ßĺōćķ ḿàţĥ ēǫũàţĩōń ŕēńďēŕēď ŵĩţĥ ĶàŢēX ƒŕōḿ à ĹàŢēX śōũŕćē śţŕĩńĝ."], @@ -2310,6 +2325,7 @@ "ogAuyr": ["ßàćķ ţō ďōćũḿēńţ vĩēŵ"], "ohUJJM": ["Ƥĺũĝĩńś"], "omXJcU": ["Ćĺōśē ōţĥēŕś"], + "onTlRk": ["Ćōũĺďń'ţ ũƥďàţē śķĩĺĺś śĥàŕĩńĝ"], "optX0N": [["0"], "ĥ àĝō"], "oqB2ez": ["Ƥŕēvĩōũś ḿàţćĥ"], "osRqlv": ["Ōƥēń ďēĺēţē ćōńƒĩŕḿàţĩōń ƒōŕ ţĥē śēĺēćţēď ƒĩĺē-ţŕēē ĩţēḿś."], @@ -2329,6 +2345,7 @@ "pEQFsW": ["Ńō ḿàţćĥĩńĝ ƥŕōĴēćţś."], "pFagOn": ["Śōũŕćē ēďĩţĩńĝ"], "pHYP6v": ["Ōƥēń <0>Ćũśţōḿĩźē ĩń ţĥē śĩďēƀàŕ <1/> <2>Śķĩĺĺś."], + "pQf2Ux": [".ōķ/śķĩĺĺś ĩś ƀàćķ ţō ĺōćàĺ-ōńĺŷ."], "pRc-Im": ["Ũńďōńē àĺĺ ēďĩţś ōń ", ["docName"]], "pTPqMK": ["Ţōĝĝĺē à ƒēńćēď ćōďē ƀĺōćķ."], "pWT04I": ["Ćĥēćķĩńĝ..."], @@ -2372,6 +2389,7 @@ "qSKu2v": ["Śōũŕćē ńàvĩĝàţĩōń àńď śēĺēćţĩōń"], "qSxaOz": ["Àććēƥţ ţĥēĩŕ ďēĺēţĩōń"], "qUB_o_": ["Àũţō-śŷńć ĩś ōƒƒ — ŷōũŕ ēďĩţś śţàŷ ĺōćàĺ ũńţĩĺ ŷōũ ćōḿḿĩţ àńď ƥũśĥ ḿàńũàĺĺŷ."], + "qXN416": ["Ćōũĺďń'ţ śĥàŕē śķĩĺĺś: ĩńţēŕńàĺ ēŕŕōŕ. Ƥĺēàśē ŕēśţàŕţ ţĥē àƥƥ."], "qXg5W9": ["Ƒĩĺē śàvēś ţō <0>~/Ďōŵńĺōàďś/ōƥēńķńōŵĺēďĝē.śķĩĺĺ."], "qXsafh": ["Ƥũƀĺĩśĥ ţō ĜĩţĤũƀ"], "qZv5ZR": ["Ćōńƒĩĝ śĥàŕĩńĝ ţōĝĝĺē ƒàĩĺēď"], @@ -2762,6 +2780,7 @@ "Ţĥē ƥŕōƥēŕţĩēś ƀĺōćķ àţ ţĥē ţōƥ ōƒ ţĥĩś ďōć ĥàś à ƒōŕḿàţţĩńĝ ēŕŕōŕ. Śŵĩţćĥ ţō śōũŕćē ḿōďē ţō ƒĩx ĩţ." ], "zbymaY": [["0"], "ḿ àĝō"], + "zcmYx4": ["ĝĩţ ćōńƒĩĝũŕàţĩōń ĩś ũńàvàĩĺàƀĺē"], "zd00pH": ["Ćōƥŷ Ƥàţĥ"], "zgSFyG": ["Ēxţēŕńàĺ ĺĩńķ"], "zkz0Fg": ["Śēţ ũƥ śŷńćĩńĝ"], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 2d0c30cd..aa7c0957 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -29,6 +29,14 @@ msgstr "" msgid ".ok folders" msgstr "" +#: src/components/settings/SharingSection.tsx +msgid ".ok/skills is back to local-only." +msgstr "" + +#: src/components/settings/SharingSection.tsx +msgid ".ok/skills is committable so your team gets your skills; the rest of .ok/ stays on this computer." +msgstr "" + #. placeholder {0}: skill.name #. placeholder {1}: label(result.hosts) #: src/components/skill-actions.tsx @@ -2318,6 +2326,18 @@ msgstr "" msgid "Couldn't send your prompt — please try again." msgstr "" +#: src/components/SkillsSidebarSection.tsx +msgid "Couldn't share skills" +msgstr "" + +#: src/components/SkillsSidebarSection.tsx +msgid "Couldn't share skills — {detail}." +msgstr "" + +#: src/components/SkillsSidebarSection.tsx +msgid "Couldn't share skills: internal error. Please restart the app." +msgstr "" + #. placeholder {0}: result.error #: src/components/skill-actions.tsx msgid "Couldn't uninstall skill: {0}" @@ -2333,6 +2353,14 @@ msgstr "" msgid "Couldn't update skill: {0}" msgstr "" +#: src/components/settings/SharingSection.tsx +msgid "Couldn't update skills sharing" +msgstr "" + +#: src/components/settings/SharingSection.tsx +msgid "Couldn't update skills sharing — {detail}." +msgstr "" + #: src/components/ConsentDialogBody.tsx msgid "Counting markdown files" msgstr "" @@ -3146,6 +3174,10 @@ msgstr "" msgid "Error:" msgstr "" +#: src/components/settings/SharingSection.tsx +msgid "Exception: skills are shared" +msgstr "" + #: src/components/NewWorktreeDialog.tsx msgid "Existing branch <0>{trimmed} will be checked out into its own window, under <1>.ok/worktrees/." msgstr "" @@ -3725,6 +3757,11 @@ msgstr "" msgid "Git auto-sync" msgstr "" +#: src/components/settings/SharingSection.tsx +#: src/components/SkillsSidebarSection.tsx +msgid "git configuration is unavailable" +msgstr "" + #: src/components/SyncStatusBadge.tsx msgid "Git identity isn't set — commits use a default author. Set yours so teammates see your name." msgstr "" @@ -5035,6 +5072,11 @@ msgstr "" msgid "No git repository — config sharing does not apply here." msgstr "" +#: src/components/settings/SharingSection.tsx +#: src/components/SkillsSidebarSection.tsx +msgid "no git repository here" +msgstr "" + #: src/components/settings/SkillsManagerSection.tsx msgid "No global skills yet." msgstr "" @@ -6998,6 +7040,10 @@ msgstr "" msgid "Share folder" msgstr "" +#: src/components/SkillsSidebarSection.tsx +msgid "Share skills" +msgstr "" + #: src/components/SharingModeField.tsx msgid "Share this setup with your team?" msgstr "" @@ -7227,6 +7273,10 @@ msgstr "" msgid "Skills" msgstr "" +#: src/components/SkillsSidebarSection.tsx +msgid "Skills are now shared. Commit .ok/skills to share them with your team." +msgstr "" + #: src/components/settings/SkillsManagerSection.tsx msgid "Skills teach agents repeatable tasks. Author them here; install a skill to project it into your editors' skill folders." msgstr "" @@ -7772,6 +7822,11 @@ msgstr "" msgid "The formula <0>c = √(a² + b²) renders inline." msgstr "" +#: src/components/settings/SharingSection.tsx +#: src/components/SkillsSidebarSection.tsx +msgid "the git exclude file isn't writable" +msgstr "" + #: src/components/TemplateProperties.tsx msgid "The menu label agents pick this template by (required)." msgstr "" @@ -7984,6 +8039,10 @@ msgstr "" msgid "This project has no git repository — sharing mode does not apply. Initialize a repo to enable the toggle." msgstr "" +#: src/components/SkillsSidebarSection.tsx +msgid "This project is local-only, so .ok/skills is hidden from git and these skills can't be committed or shared. Share just the skills folder — the rest of your OK config stays on this computer." +msgstr "" + #. placeholder {0}: info.appRuntime #: src/lib/install-server-drift-listener.ts msgid "This project is running a different, incompatible build of OpenKnowledge than this app (v{0})." @@ -8221,6 +8280,10 @@ msgstr "" msgid "Underline" msgstr "" +#: src/components/settings/SharingSection.tsx +msgid "Undo" +msgstr "" + #: src/components/ActivityPanelFileRow.tsx msgid "Undo all" msgstr "" diff --git a/packages/app/tests/stress/fixtures/handoff-mocks.ts b/packages/app/tests/stress/fixtures/handoff-mocks.ts index 717b32c9..62c1741c 100644 --- a/packages/app/tests/stress/fixtures/handoff-mocks.ts +++ b/packages/app/tests/stress/fixtures/handoff-mocks.ts @@ -439,11 +439,16 @@ export async function installHandoffMocks(page: Page, cfg: HandoffMockConfig): P mode: 'shared' as const, excluded: [], trackedUpstream: [], + skillsShared: false as const, }) satisfies import('@/lib/desktop-bridge-types').OkSharingStatusResult, setMode: async () => ({ kind: 'applied' as const, mode: 'shared' as const, }), + setSkillsShared: async () => ({ + kind: 'applied' as const, + mode: 'local-only' as const, + }), }, bugReport: { create: async () => ({ ok: false as const, error: 'test mock' }), diff --git a/packages/cli/src/commands/sharing/status.ts b/packages/cli/src/commands/sharing/status.ts index e3eaba26..590335e4 100644 --- a/packages/cli/src/commands/sharing/status.ts +++ b/packages/cli/src/commands/sharing/status.ts @@ -14,6 +14,7 @@ import { getOkArtifactPaths, probeTrackedOkPaths, readSharingMode, + readSkillsShared, type SharingMode, } from '../../sharing/git-exclude.ts'; import { accent, info, success, warning } from '../../ui/colors.ts'; @@ -27,6 +28,8 @@ interface StatusJsonReport { type: 'sharing-status'; projectRoot: string; mode: SharingMode; + /** True when local-only but `.ok/skills/` is carved back out as shareable. */ + skillsShared: boolean; excluded: string[]; trackedUpstream: string[]; } @@ -39,6 +42,7 @@ export function sharingStatusCommand(): Command { .action(async (opts: StatusOptions) => { const projectRoot = resolve(opts.project ?? process.cwd()); const mode = readSharingMode(projectRoot); + const skillsShared = readSkillsShared(projectRoot); const excluded = [...getExcludedOkPaths(projectRoot)]; const trackedUpstream = probeTrackedOkPaths( projectRoot, @@ -50,6 +54,7 @@ export function sharingStatusCommand(): Command { type: 'sharing-status', projectRoot, mode, + skillsShared, excluded, trackedUpstream, }; @@ -59,6 +64,15 @@ export function sharingStatusCommand(): Command { const lines: string[] = []; lines.push(`OpenKnowledge sharing mode: ${formatMode(mode)}`); + if (skillsShared) { + // In the carve state the blanket `.ok/` line is replaced by + // `**/.ok/* + !**/.ok/skills/`, so `getExcludedOkPaths` no longer lists + // `.ok/`. Call it out explicitly so the excluded list below isn't + // misread as ".ok/ is shared". + lines.push( + ` ${accent('.ok/skills')} is committable (carved out); the rest of .ok/ stays local.`, + ); + } lines.push(''); lines.push(`Excluded from git via ${accent('.git/info/exclude')}:`); if (excluded.length === 0) { @@ -77,6 +91,13 @@ export function sharingStatusCommand(): Command { lines.push( `Toggle with: ${info(mode === 'local-only' ? 'ok config-sharing share' : 'ok config-sharing unshare')}`, ); + if (skillsShared) { + // Disambiguate: the toggle above promotes the WHOLE project to shared. + // Undoing only the skills carve-out is a desktop-app action today. + lines.push( + ' (that shares the whole project; to undo only the skills carve-out, use the desktop app: Settings > Config sharing)', + ); + } process.stdout.write(`${lines.join('\n')}\n`); }); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 78f4a9bf..1849b1d0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -146,7 +146,9 @@ export { getOkArtifactPaths, probeTrackedOkPaths, readSharingMode, + readSkillsShared, removeOkPathsFromGitExclude, type SharingMode, + setSkillsShared, type TrackedRefusal, } from './sharing/git-exclude.ts'; diff --git a/packages/cli/src/sharing/git-exclude.test.ts b/packages/cli/src/sharing/git-exclude.test.ts index b9f42825..0ce5665b 100644 --- a/packages/cli/src/sharing/git-exclude.test.ts +++ b/packages/cli/src/sharing/git-exclude.test.ts @@ -10,7 +10,9 @@ import { getOkArtifactPaths, probeTrackedOkPaths, readSharingMode, + readSkillsShared, removeOkPathsFromGitExclude, + setSkillsShared, } from './git-exclude.ts'; function uniqueDir(prefix: string): string { @@ -572,3 +574,101 @@ describe('formatTrackedRemediation', () => { expect(out).toContain('your teammates will see a deletion on their next pull'); }); }); + +describe('setSkillsShared / readSkillsShared (skills carve-out)', () => { + let dir: string; + beforeEach(() => { + dir = uniqueDir('skills-carve-test'); + initGitRepo(dir); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** Untracked-file set as git sees it (respects .git/info/exclude). */ + function untracked(): string[] { + return execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { + cwd: dir, + encoding: 'utf-8', + }) + .split('\n') + .filter((l) => l.startsWith('??')) + .map((l) => l.slice(3).trim()); + } + + it('rewrites blanket .ok/ into the carve spelling and reports skills-shared', () => { + writeExclude(dir, '.ok/\n.okignore\n'); + const result = setSkillsShared(dir, true); + expect(result.kind).toBe('updated'); + const content = readExclude(dir); + expect(content).not.toMatch(/^\.ok\/$/m); // blanket line gone + expect(content).toContain('**/.ok/*'); + expect(content).toContain('!**/.ok/skills/'); + expect(content).toContain('.okignore'); // unrelated artifact preserved + expect(readSkillsShared(dir)).toBe(true); + // Still local-only for the rest of .ok/. + expect(readSharingMode(dir)).toBe('local-only'); + }); + + it('actually makes .ok/skills/ visible to git while the rest of .ok/ stays hidden', () => { + // The crux: git refuses to re-include under an excluded parent dir, so the + // carve MUST use the children-exclude spelling to work at all. + mkdirSync(join(dir, '.ok', 'skills', 'my-skill'), { recursive: true }); + mkdirSync(join(dir, '.ok', 'local'), { recursive: true }); + writeFileSync(join(dir, '.ok', 'skills', 'my-skill', 'SKILL.md'), '# s\n'); + writeFileSync(join(dir, '.ok', 'local', 'state.json'), '{}\n'); + writeFileSync(join(dir, '.ok', 'config.yml'), 'x: 1\n'); + + writeExclude(dir, '.ok/\n'); + // Blanket: everything under .ok/ hidden. + expect(untracked().some((f) => f.startsWith('.ok/'))).toBe(false); + + setSkillsShared(dir, true); + const files = untracked(); + expect(files).toContain('.ok/skills/my-skill/SKILL.md'); + expect(files).not.toContain('.ok/local/state.json'); + expect(files).not.toContain('.ok/config.yml'); + }); + + it('reverts the carve back to blanket .ok/ when skills sharing is turned off', () => { + writeExclude(dir, '.okignore\n**/.ok/*\n!**/.ok/skills/\n'); + expect(readSkillsShared(dir)).toBe(true); + setSkillsShared(dir, false); + const content = readExclude(dir); + expect(content).toMatch(/^\.ok\/$/m); + expect(content).not.toContain('**/.ok/*'); + expect(content).not.toContain('!**/.ok/skills/'); + expect(readSkillsShared(dir)).toBe(false); + expect(readSharingMode(dir)).toBe('local-only'); + }); + + it('readSharingMode reports local-only from the carve lines alone', () => { + writeExclude(dir, '**/.ok/*\n!**/.ok/skills/\n'); + expect(readSharingMode(dir)).toBe('local-only'); + expect(readSkillsShared(dir)).toBe(true); + }); + + it('removeOkPaths (share) strips the carve lines so nothing is left excluding .ok/', () => { + writeExclude(dir, '**/.ok/*\n!**/.ok/skills/\n.okignore\n'); + removeOkPathsFromGitExclude(dir, getOkArtifactPaths(dir)); + const content = readExclude(dir); + expect(content).not.toContain('**/.ok/*'); + expect(content).not.toContain('!**/.ok/skills/'); + expect(readSharingMode(dir)).toBe('shared'); + expect(readSkillsShared(dir)).toBe(false); + }); + + it('round-trips: local-only -> skills-shared -> off -> shared leaves no OK .ok lines', () => { + writeExclude(dir, ''); + addOkPathsToGitExclude(dir, getOkArtifactPaths(dir)); + expect(readSharingMode(dir)).toBe('local-only'); + setSkillsShared(dir, true); + expect(readSkillsShared(dir)).toBe(true); + setSkillsShared(dir, false); + expect(readSkillsShared(dir)).toBe(false); + removeOkPathsFromGitExclude(dir, getOkArtifactPaths(dir)); + expect(readSharingMode(dir)).toBe('shared'); + const content = readExclude(dir); + expect(content).not.toContain('.ok/'); + }); +}); diff --git a/packages/cli/src/sharing/git-exclude.ts b/packages/cli/src/sharing/git-exclude.ts index 623944f8..089b4508 100644 --- a/packages/cli/src/sharing/git-exclude.ts +++ b/packages/cli/src/sharing/git-exclude.ts @@ -31,6 +31,7 @@ import { OK_DIR, parseInstalledSkills, RESERVED_PROJECT_SKILL_NAME, + SKILL_CONTENT_ROOT, } from '@inkeep/open-knowledge-core'; // `resolveGitDirDetailed` is in the `node:fs`-importing subpath of core — // the barrel deliberately omits it to keep the main entry browser-safe @@ -53,6 +54,22 @@ const CLAUDE_LAUNCH_JSON = '.claude/launch.json'; */ const OK_IGNORE_FILENAME = '.okignore'; +/** `.ok/` blanket exclude — the default local-only spelling for the whole + * `.ok/` tree (unanchored, matches every `.ok/` at any depth). Emitted as + * the first entry of `getOkArtifactPaths`. */ +const OK_BLANKET = `${OK_DIR}/`; + +// Skills carve-out spelling. `.ok/skills/` is shareable content (skills-as- +// content), NOT machine-local config, yet OK_BLANKET hides it along with the +// rest of `.ok/` in local-only mode. Git refuses to re-include a path whose +// parent directory is excluded, so the carve cannot be a `!` line appended +// after `.ok/` — it REPLACES the blanket with a children-exclude plus a skills +// re-include. Both are `**/`-prefixed so they stay content.dir-independent and +// reach folder-nested `.ok/` dirs, matching OK_BLANKET's reach. A plain +// `!.ok/skills/` after `.ok/` does NOT re-include (git prunes the excluded dir). +const OK_CARVE_CHILDREN = `**/${OK_DIR}/*`; +const OK_CARVE_SKILLS_REINCLUDE = `!**/${SKILL_CONTENT_ROOT}/`; + /** * Result of an `addOkPathsToGitExclude` / `removeOkPathsFromGitExclude` call * that completed the variant-matching pass against the exclude file. @@ -264,6 +281,12 @@ export function removeOkPathsFromGitExclude( for (const set of variantsByPath) { for (const v of set) allVariants.add(v); } + // Also strip the skills carve-out spelling so a local-only(+skills-shared) + // -> shared transition fully cleans up; otherwise the orphaned + // `**/.ok/*` line would keep excluding non-skills `.ok/` content after + // "share". These are OK-managed lines, safe to remove unconditionally. + allVariants.add(OK_CARVE_CHILDREN); + allVariants.add(OK_CARVE_SKILLS_REINCLUDE); let before: string; try { @@ -345,6 +368,10 @@ export function readSharingMode(projectRoot: string): SharingMode { for (const p of artifacts) { if (hasAnyVariant(present, p)) return 'local-only'; } + // Carve state: the blanket `.ok/` line is gone (replaced by the children- + // exclude), but the project is still local-only. Recognize it so the mode + // stays correct even if every other artifact line were manually removed. + if (present.has(OK_CARVE_CHILDREN)) return 'local-only'; return 'shared'; } @@ -373,6 +400,82 @@ export function getExcludedOkPaths(projectRoot: string): readonly string[] { return getOkArtifactPaths(projectRoot).filter((p) => hasAnyVariant(present, p)); } +/** + * True iff `.git/info/exclude` currently carves `.ok/skills/` OUT of a + * local-only `.ok/` exclusion — the project is local-only, but skills stay + * shareable. Detected by the presence of BOTH carve lines. Pure read — never + * throws, never writes. + * + * Independent of `readSharingMode` (which reports `local-only` for both the + * blanket and the carve state); this predicate distinguishes the two so the + * Skills UI can render the toggle's current position. + */ +export function readSkillsShared(projectRoot: string): boolean { + const resolved = resolveExcludePath(projectRoot); + if (resolved.kind !== 'ok' || !existsSync(resolved.path)) return false; + let content: string; + try { + content = readFileSync(resolved.path, 'utf-8'); + } catch { + return false; + } + const present = collectPresentVariants(content); + return present.has(OK_CARVE_CHILDREN) && present.has(OK_CARVE_SKILLS_REINCLUDE); +} + +/** + * Toggle whether `.ok/skills/` stays shareable while the rest of `.ok/` + * remains local-only. Swaps the `.ok/` representation in `.git/info/exclude` + * between the blanket spelling (`OK_BLANKET`, skills hidden) and the carve + * spelling (`OK_CARVE_CHILDREN` + `OK_CARVE_SKILLS_REINCLUDE`, skills visible). + * Every OTHER OK-artifact exclude line is left untouched, so the local-only + * posture for config / MCP / editor bundles is preserved. + * + * Mechanical — callers gate WHEN it is offered (the desktop Skills UI shows it + * only in local-only mode). `shared: true` on a project that is NOT local-only + * would create a partial exclusion; don't call it there. + * + * No tracked-files probe: turning skills sharing ON only REMOVES exclusion + * (never hides a tracked file); turning it OFF re-adds the blanket, which is + * inert against already-tracked skills — the same no-op the main + * `shared -> local-only` refusal already documents. + */ +export function setSkillsShared(projectRoot: string, shared: boolean): ExcludeWriteResult { + const resolved = resolveExcludePath(projectRoot); + if (resolved.kind !== 'ok') return resolved.result; + + let existing = ''; + if (existsSync(resolved.path)) { + try { + existing = readFileSync(resolved.path, 'utf-8'); + } catch { + return { kind: 'no-exclude', reason: 'inaccessible' }; + } + } + + // Drop every managed `.ok/` representation line (blanket variants + both + // carve lines), then append the target spelling. Non-`.ok` lines pass + // through untouched, preserving the rest of the local-only artifact set. + const managed = new Set([ + ...buildVariants(OK_BLANKET), + OK_CARVE_CHILDREN, + OK_CARVE_SKILLS_REINCLUDE, + ]); + const kept = existing.split('\n').filter((line) => !managed.has(line.trim())); + const additions = shared ? [OK_CARVE_CHILDREN, OK_CARVE_SKILLS_REINCLUDE] : [OK_BLANKET]; + + const body = kept.join('\n'); + const separator = body.length === 0 || body.endsWith('\n') ? '' : '\n'; + const next = `${body}${separator}${additions.join('\n')}\n`; + + try { + writeFileSync(resolved.path, next, 'utf-8'); + } catch { + return { kind: 'no-exclude', reason: 'inaccessible' }; + } + return { kind: 'updated', appended: additions, alreadyPresent: [], removed: [] }; +} + /** * Pure probe — checks which of `paths` (if any) are currently tracked * upstream via `git ls-files --error-unmatch`. Used at exactly one site diff --git a/packages/core/src/desktop-bridge.ts b/packages/core/src/desktop-bridge.ts index c38e2218..46612a49 100644 --- a/packages/core/src/desktop-bridge.ts +++ b/packages/core/src/desktop-bridge.ts @@ -818,6 +818,8 @@ export interface OkSharingStatusResult { readonly mode: 'shared' | 'local-only' | 'no-git'; readonly excluded: readonly string[]; readonly trackedUpstream: readonly string[]; + /** True when local-only but `.ok/skills/` is carved back out as shareable. */ + readonly skillsShared: boolean; } export type OkSharingSetModeResult = @@ -1343,6 +1345,8 @@ export interface OkDesktopBridge { sharing: { status(): Promise; setMode(mode: 'shared' | 'local-only'): Promise; + /** Toggle `.ok/skills/` shareability within local-only mode. */ + setSkillsShared(shared: boolean): Promise; }; /** diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 84944ea9..c296f528 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -237,7 +237,11 @@ import { type LocalOpDeps, } from './ipc/local-op.ts'; import { handleSeedApply, handleSeedListPacks, handleSeedPlan } from './ipc/seed.ts'; -import { handleSharingSetMode, handleSharingStatus } from './ipc/sharing.ts'; +import { + handleSharingSetMode, + handleSharingSetSkillsShared, + handleSharingStatus, +} from './ipc/sharing.ts'; import { detectProtocol as detectProtocolImpl, recordHandoff as recordHandoffImpl, @@ -3901,6 +3905,9 @@ function registerIpcHandlers() { if (request.kind === 'status') { return handleSharingStatus(ctx.projectPath); } + if (request.kind === 'set-skills-shared') { + return handleSharingSetSkillsShared(ctx.projectPath, request.shared); + } const mode: 'shared' | 'local-only' = request.mode === 'local-only' ? 'local-only' : 'shared'; return handleSharingSetMode(ctx.projectPath, mode); }); diff --git a/packages/desktop/src/main/ipc/sharing.test.ts b/packages/desktop/src/main/ipc/sharing.test.ts index 47a4fe3b..a2d18b44 100644 --- a/packages/desktop/src/main/ipc/sharing.test.ts +++ b/packages/desktop/src/main/ipc/sharing.test.ts @@ -1,5 +1,6 @@ /** - * IPC handler tests for the `ok:sharing:dispatch` channel (status / set-mode). + * IPC handler tests for the `ok:sharing:dispatch` channel (status / set-mode / + * set-skills-shared). * * Exercise the pure handler functions directly against a tmpdir-backed git * repo. The IPC wrapping in main/index.ts is one createHandler call; its @@ -13,7 +14,11 @@ import { execFileSync } from 'node:child_process'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { handleSharingSetMode, handleSharingStatus } from './sharing.ts'; +import { + handleSharingSetMode, + handleSharingSetSkillsShared, + handleSharingStatus, +} from './sharing.ts'; function uniqueDir(prefix: string): string { return resolve(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -126,3 +131,66 @@ describe('handleSharingSetMode', () => { } }); }); + +describe('handleSharingSetSkillsShared (local-only skills carve-out)', () => { + let dir: string; + beforeEach(() => { + dir = uniqueDir('sharing-skills-handler'); + initGitRepo(dir); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('carves skills out of a local-only project and reports it in status', () => { + handleSharingSetMode(dir, 'local-only'); + expect(handleSharingStatus(dir).skillsShared).toBe(false); + + const result = handleSharingSetSkillsShared(dir, true); + expect(result.kind).toBe('applied'); + if (result.kind !== 'applied') throw new Error('expected applied'); + // Still local-only for the rest of .ok/ — only skills were carved out. + expect(result.mode).toBe('local-only'); + + const status = handleSharingStatus(dir); + expect(status.mode).toBe('local-only'); + expect(status.skillsShared).toBe(true); + }); + + it('turning skills sharing back off restores full local-only', () => { + handleSharingSetMode(dir, 'local-only'); + handleSharingSetSkillsShared(dir, true); + handleSharingSetSkillsShared(dir, false); + const status = handleSharingStatus(dir); + expect(status.mode).toBe('local-only'); + expect(status.skillsShared).toBe(false); + }); + + it('actually unhides .ok/skills while keeping .ok/local hidden from git', () => { + mkdirSync(join(dir, '.ok', 'skills', 's'), { recursive: true }); + mkdirSync(join(dir, '.ok', 'local'), { recursive: true }); + writeFileSync(join(dir, '.ok', 'skills', 's', 'SKILL.md'), '# s\n', 'utf-8'); + writeFileSync(join(dir, '.ok', 'local', 'state.json'), '{}\n', 'utf-8'); + + handleSharingSetMode(dir, 'local-only'); + handleSharingSetSkillsShared(dir, true); + + const untracked = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { + cwd: dir, + encoding: 'utf-8', + }); + expect(untracked).toContain('.ok/skills/s/SKILL.md'); + expect(untracked).not.toContain('.ok/local/state.json'); + }); + + it('returns `no-exclude` / `no-git` for a non-git directory', () => { + const nonGit = uniqueDir('sharing-skills-nongit'); + mkdirSync(nonGit, { recursive: true }); + try { + const result = handleSharingSetSkillsShared(nonGit, true); + expect(result).toMatchObject({ kind: 'no-exclude', reason: 'no-git' }); + } finally { + rmSync(nonGit, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/desktop/src/main/ipc/sharing.ts b/packages/desktop/src/main/ipc/sharing.ts index 75682dad..45cd65a9 100644 --- a/packages/desktop/src/main/ipc/sharing.ts +++ b/packages/desktop/src/main/ipc/sharing.ts @@ -21,8 +21,10 @@ import { getOkArtifactPaths, probeTrackedOkPaths, readSharingMode, + readSkillsShared, removeOkPathsFromGitExclude, type SharingMode, + setSkillsShared, } from '@inkeep/open-knowledge'; export interface SharingStatusResult { @@ -33,6 +35,10 @@ export interface SharingStatusResult { mode: SharingMode; excluded: string[]; trackedUpstream: string[]; + /** True when local-only but `.ok/skills/` is carved back out as shareable + * (see `readSkillsShared`). Always false outside local-only. Drives the + * Skills UI "share skills" toggle position. */ + skillsShared: boolean; } export type SharingSetModeResult = @@ -57,16 +63,42 @@ export function handleSharingStatus(projectPath: string): SharingStatusResult { projectPath, getOkArtifactPaths(projectPath), ).tracked; - return { kind: 'status', mode, excluded, trackedUpstream }; + const skillsShared = readSkillsShared(projectPath); + return { kind: 'status', mode, excluded, trackedUpstream, skillsShared }; } catch { // `probeTrackedOkPaths` shells out to `git` (which may be absent from // Electron's inherited PATH), and the fs reads can hit a TOCTOU / // permission throw. Degrade to a safe status so SharingSection renders // instead of the IPC promise rejecting and stranding it in its Skeleton. - return { kind: 'status', mode: 'no-git', excluded: [], trackedUpstream: [] }; + return { + kind: 'status', + mode: 'no-git', + excluded: [], + trackedUpstream: [], + skillsShared: false, + }; } } +/** + * Toggle whether `.ok/skills/` stays shareable while the rest of `.ok/` + * remains local-only. Delegates to the shared `setSkillsShared` primitive, so + * desktop and CLI cannot drift. Only meaningful in local-only mode; the + * renderer gates the toggle's visibility. Returns the same discriminated + * result shape as `set-mode` (no `refused-tracked` arm — carving skills OUT + * only removes exclusion, it never hides a tracked file). + */ +export function handleSharingSetSkillsShared( + projectPath: string, + shared: boolean, +): SharingSetModeResult { + const result = setSkillsShared(projectPath, shared); + if (result.kind === 'no-exclude') { + return { kind: 'no-exclude', reason: result.reason }; + } + return { kind: 'applied', mode: readSharingMode(projectPath) }; +} + /** * Toggle the mode. `local-only` calls `addOkPathsToGitExclude` which runs * the tracked-files probe internally; on refusal we return the diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 7e3f1397..25b958f5 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -462,9 +462,10 @@ const bridge: OkDesktopBridge = { }, sharing: { - // The two-method surface maps onto a single discriminated channel - // (`ok:sharing:dispatch`) so the codebase's hand-rolled-channel cap is respected. - // Each method narrows the result type via the typed-IPC layer. + // The three-method surface (status / setMode / setSkillsShared) maps onto a + // single discriminated channel (`ok:sharing:dispatch`) so the codebase's + // hand-rolled-channel cap is respected. Each method narrows the result type + // via the typed-IPC layer. status: async () => { const result = await invoke('ok:sharing:dispatch', { kind: 'status' }); if (result.kind !== 'status') { @@ -479,6 +480,13 @@ const bridge: OkDesktopBridge = { } return result; }, + setSkillsShared: async (shared: boolean) => { + const result = await invoke('ok:sharing:dispatch', { kind: 'set-skills-shared', shared }); + if (result.kind === 'status') { + throw new Error('ok:sharing:dispatch: expected set-skills-shared result, got status'); + } + return result; + }, }, bugReport: { diff --git a/packages/desktop/src/shared/bridge-contract.ts b/packages/desktop/src/shared/bridge-contract.ts index 7f45bcc3..0a697104 100644 --- a/packages/desktop/src/shared/bridge-contract.ts +++ b/packages/desktop/src/shared/bridge-contract.ts @@ -535,6 +535,8 @@ export interface OkSharingStatusResult { readonly mode: 'shared' | 'local-only' | 'no-git'; readonly excluded: readonly string[]; readonly trackedUpstream: readonly string[]; + /** True when local-only but `.ok/skills/` is carved back out as shareable. */ + readonly skillsShared: boolean; } export type OkSharingSetModeResult = @@ -1531,6 +1533,8 @@ export interface OkDesktopBridge { sharing: { status(): Promise; setMode(mode: 'shared' | 'local-only'): Promise; + /** Toggle `.ok/skills/` shareability within local-only mode. */ + setSkillsShared(shared: boolean): Promise; }; /** diff --git a/packages/desktop/src/shared/ipc-channels.ts b/packages/desktop/src/shared/ipc-channels.ts index 15f55f57..bb2e22af 100644 --- a/packages/desktop/src/shared/ipc-channels.ts +++ b/packages/desktop/src/shared/ipc-channels.ts @@ -109,6 +109,8 @@ export interface OkSharingStatusResult { readonly mode: 'shared' | 'local-only' | 'no-git'; readonly excluded: readonly string[]; readonly trackedUpstream: readonly string[]; + /** True when local-only but `.ok/skills/` is carved back out as shareable. */ + readonly skillsShared: boolean; } export type OkSharingSetModeResult = @@ -795,16 +797,21 @@ export interface RequestChannels { 'ok:project:get-info': { args: []; result: OkDesktopConfig }; /** - * Single-channel discriminated surface - * for both the read (`status`) and write (`set-mode`) operations. Folded - * into one channel to stay under the hand-rolled-channel scale-match - * cap; the discriminated args/result keeps the per-operation typing - * crisp at the call sites (preload + handler). Internally main dispatches - * on `request.kind`. The renderer's `bridge.sharing.{status,setMode}` - * surface keeps the ergonomic split. + * Single-channel discriminated surface for the read (`status`) and the two + * writes (`set-mode`, `set-skills-shared`). Folded into one channel to stay + * under the hand-rolled-channel scale-match cap; the discriminated + * args/result keeps the per-operation typing crisp at the call sites (preload + * + handler). Internally main dispatches on `request.kind`. The renderer's + * `bridge.sharing.{status,setMode,setSkillsShared}` surface keeps the + * ergonomic split. */ 'ok:sharing:dispatch': { - args: [request: { kind: 'status' } | { kind: 'set-mode'; mode: 'shared' | 'local-only' }]; + args: [ + request: + | { kind: 'status' } + | { kind: 'set-mode'; mode: 'shared' | 'local-only' } + | { kind: 'set-skills-shared'; shared: boolean }, + ]; result: OkSharingResult; }; /**