quoxcli 0.0.0-stage → 0.3.1
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/LICENSE +35 -0
- package/README.md +144 -2
- package/bin/quox.js +265 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +151 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +63 -0
- package/lib/api/endpoints/assistants.js +62 -0
- package/lib/api/endpoints/audit.js +14 -0
- package/lib/api/endpoints/auth.js +50 -0
- package/lib/api/endpoints/awareness.js +34 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/backup-schedules.js +20 -0
- package/lib/api/endpoints/backups.js +86 -0
- package/lib/api/endpoints/brain.js +140 -0
- package/lib/api/endpoints/budgets.js +62 -0
- package/lib/api/endpoints/calendarq.js +71 -0
- package/lib/api/endpoints/catalog.js +14 -0
- package/lib/api/endpoints/chat.js +61 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +68 -0
- package/lib/api/endpoints/conversations.js +46 -0
- package/lib/api/endpoints/custom-roles.js +92 -0
- package/lib/api/endpoints/divisions.js +56 -0
- package/lib/api/endpoints/domains.js +38 -0
- package/lib/api/endpoints/engagement.js +231 -0
- package/lib/api/endpoints/engine.js +11 -0
- package/lib/api/endpoints/entity.js +50 -0
- package/lib/api/endpoints/executions.js +45 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +40 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +66 -0
- package/lib/api/endpoints/inbox.js +52 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +242 -0
- package/lib/api/endpoints/invites.js +19 -0
- package/lib/api/endpoints/llm-policy.js +43 -0
- package/lib/api/endpoints/lucid.js +89 -0
- package/lib/api/endpoints/members.js +32 -0
- package/lib/api/endpoints/memory.js +94 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +184 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +68 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +152 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/prompts.js +69 -0
- package/lib/api/endpoints/qlarity.js +122 -0
- package/lib/api/endpoints/quoxbox.js +55 -0
- package/lib/api/endpoints/quoxchat.js +54 -0
- package/lib/api/endpoints/quoxflow.js +32 -0
- package/lib/api/endpoints/quoxplan.js +121 -0
- package/lib/api/endpoints/quoxseo.js +114 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +53 -0
- package/lib/api/endpoints/scim.js +46 -0
- package/lib/api/endpoints/scopes.js +14 -0
- package/lib/api/endpoints/security.js +69 -0
- package/lib/api/endpoints/setup.js +90 -0
- package/lib/api/endpoints/skills.js +200 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/tags.js +32 -0
- package/lib/api/endpoints/teams.js +56 -0
- package/lib/api/endpoints/telephony.js +46 -0
- package/lib/api/endpoints/tg.js +56 -0
- package/lib/api/endpoints/tools.js +50 -0
- package/lib/api/endpoints/traces.js +157 -0
- package/lib/api/endpoints/triggers.js +96 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +585 -0
- package/lib/api/endpoints/vault.js +192 -0
- package/lib/api/endpoints/voice.js +22 -0
- package/lib/api/endpoints/volt.js +84 -0
- package/lib/api/endpoints/ward.js +47 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +169 -0
- package/lib/api/endpoints/x.js +32 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +375 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +337 -0
- package/lib/commands/agentAttach.js +1528 -0
- package/lib/commands/approvals.js +119 -0
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +169 -0
- package/lib/commands/auth.js +464 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +377 -0
- package/lib/commands/brain.js +1085 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +170 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +295 -0
- package/lib/commands/claude.js +380 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +1016 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +105 -0
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +96 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +326 -0
- package/lib/commands/devSupervisor.js +1062 -0
- package/lib/commands/discord.js +132 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +347 -0
- package/lib/commands/engagement.js +597 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +165 -0
- package/lib/commands/fleet.js +1189 -0
- package/lib/commands/governance.js +647 -0
- package/lib/commands/gripe.js +355 -0
- package/lib/commands/hat.js +374 -0
- package/lib/commands/helpers.js +96 -0
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +173 -0
- package/lib/commands/instagram.js +264 -0
- package/lib/commands/integration.js +100 -0
- package/lib/commands/inventory.js +115 -0
- package/lib/commands/invites.js +45 -0
- package/lib/commands/licence.js +139 -0
- package/lib/commands/linkedin.js +178 -0
- package/lib/commands/llm-policy.js +117 -0
- package/lib/commands/llm.js +45 -0
- package/lib/commands/lucid.js +597 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +361 -0
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +581 -0
- package/lib/commands/monitoring.js +86 -0
- package/lib/commands/motion.js +22 -0
- package/lib/commands/notes.js +1246 -0
- package/lib/commands/notesBatch.js +160 -0
- package/lib/commands/notesBridges.js +56 -0
- package/lib/commands/notesLinks.js +60 -0
- package/lib/commands/notification.js +57 -0
- package/lib/commands/nucleus.js +123 -0
- package/lib/commands/objective.js +240 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +329 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +276 -0
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +944 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +649 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +706 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +275 -0
- package/lib/commands/quoxseo.js +378 -0
- package/lib/commands/reach.js +159 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +187 -0
- package/lib/commands/safety.js +107 -0
- package/lib/commands/scim.js +88 -0
- package/lib/commands/scope.js +58 -0
- package/lib/commands/security.js +157 -0
- package/lib/commands/server.js +292 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +569 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +687 -0
- package/lib/commands/slot.js +1451 -0
- package/lib/commands/sso.js +229 -0
- package/lib/commands/status.js +137 -0
- package/lib/commands/tag.js +91 -0
- package/lib/commands/team.js +142 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +304 -0
- package/lib/commands/trace.js +530 -0
- package/lib/commands/trigger.js +223 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +1447 -0
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +213 -0
- package/lib/commands/volt.js +570 -0
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +167 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +660 -0
- package/lib/commands/x.js +448 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/aee.js +53 -0
- package/lib/core/agentAlias.js +163 -0
- package/lib/core/approvalContext.js +61 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +501 -0
- package/lib/core/banner.js +143 -0
- package/lib/core/brainPrime.js +90 -0
- package/lib/core/callerSource.js +129 -0
- package/lib/core/claudeLs.js +94 -0
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +693 -0
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +669 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/customHats.js +168 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +227 -0
- package/lib/core/errors.js +441 -0
- package/lib/core/global-flags.js +190 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +122 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +239 -0
- package/lib/core/peek.js +126 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentAlarm.js +282 -0
- package/lib/core/residentAuth.js +243 -0
- package/lib/core/residentPump.js +229 -0
- package/lib/core/residentSeed.js +106 -0
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +224 -0
- package/lib/core/slotKeeper.js +527 -0
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +933 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +92 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +116 -0
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +952 -0
- package/lib/quoxon/app.js +4032 -0
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +82 -0
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +479 -0
- package/lib/quoxon/connect.js +654 -0
- package/lib/quoxon/connectCard.js +45 -0
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +34 -0
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +76 -0
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +677 -0
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +237 -0
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +197 -0
- package/lib/quoxon/intent.js +52 -0
- package/lib/quoxon/keys.js +164 -0
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +176 -0
- package/lib/quoxon/markdown.js +341 -0
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +311 -0
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +70 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +182 -0
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +106 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +87 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +263 -0
- package/lib/quoxon/onboarding/credential.js +108 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +134 -0
- package/lib/quoxon/onboarding/index.js +94 -0
- package/lib/quoxon/onboarding/installRunner.js +284 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +156 -0
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +682 -0
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +98 -0
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +348 -0
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +132 -0
- package/lib/quoxon/render.js +1167 -0
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +307 -0
- package/lib/quoxon/route.js +39 -0
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +238 -0
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +88 -0
- package/lib/quoxon/slash/listView.js +30 -0
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +118 -0
- package/lib/quoxon/slash/paletteController.js +169 -0
- package/lib/quoxon/slash/panels.js +116 -0
- package/lib/quoxon/slash/picker.js +56 -0
- package/lib/quoxon/slash/restart.js +202 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +60 -0
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +120 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +141 -0
- package/lib/quoxon/textBuffer.js +416 -0
- package/lib/quoxon/theme.js +286 -0
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +188 -0
- package/lib/quoxon/web/qface.js +89 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/app.jsx +289 -0
- package/lib/tui/components/Breadcrumb.jsx +42 -0
- package/lib/tui/components/ChatInput.jsx +68 -0
- package/lib/tui/components/Confirm.jsx +32 -0
- package/lib/tui/components/DataTable.jsx +270 -0
- package/lib/tui/components/DetailPanel.jsx +241 -0
- package/lib/tui/components/HelpOverlay.jsx +67 -0
- package/lib/tui/components/Layout.jsx +129 -0
- package/lib/tui/components/Palette.jsx +141 -0
- package/lib/tui/components/SearchInput.jsx +125 -0
- package/lib/tui/components/Sparkline.jsx +55 -0
- package/lib/tui/components/StatusBar.jsx +133 -0
- package/lib/tui/components/Toast.jsx +52 -0
- package/lib/tui/hooks/useApi.js +44 -0
- package/lib/tui/hooks/useHealth.js +90 -0
- package/lib/tui/hooks/usePolledApi.js +86 -0
- package/lib/tui/hooks/useTerminalSize.js +46 -0
- package/lib/tui/index.js +56 -0
- package/lib/tui/intro/Intro.jsx +332 -0
- package/lib/tui/jsx-loader.js +32 -0
- package/lib/tui/keys.js +87 -0
- package/lib/tui/registry.js +49 -0
- package/lib/tui/theme.js +103 -0
- package/lib/tui/views/AdminView.jsx +398 -0
- package/lib/tui/views/AgentView.jsx +204 -0
- package/lib/tui/views/BackupView.jsx +225 -0
- package/lib/tui/views/BrainView.jsx +223 -0
- package/lib/tui/views/ChatView.jsx +468 -0
- package/lib/tui/views/FilesView.jsx +218 -0
- package/lib/tui/views/FleetView.jsx +234 -0
- package/lib/tui/views/GovernanceView.jsx +453 -0
- package/lib/tui/views/HealthView.jsx +216 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +719 -0
- package/lib/tui/views/MainMenu.jsx +123 -0
- package/lib/tui/views/MemoryView.jsx +377 -0
- package/lib/tui/views/ObjectivesView.jsx +230 -0
- package/lib/tui/views/OrgView.jsx +229 -0
- package/lib/tui/views/ToolView.jsx +221 -0
- package/lib/tui/views/VaultView.jsx +422 -0
- package/lib/tui/views/WorkflowView.jsx +329 -0
- package/package.json +75 -4
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotesQ commands: governed markdown notes (Qortex notebook layer).
|
|
3
|
+
* Proxied through the collector (/api/v1/notesq/*; org + user resolved
|
|
4
|
+
* server-side from auth). Writes are revisioned; edits use optimistic
|
|
5
|
+
* concurrency (base_hash) and a stale base is a 409, never retried silently.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { basename, dirname, join, resolve } from 'path';
|
|
12
|
+
import { Option } from 'commander';
|
|
13
|
+
import { formatOutput, formatDetail, printSuccess } from '../core/output.js';
|
|
14
|
+
import { QuoxApiError, QuoxError } from '../core/errors.js';
|
|
15
|
+
import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
|
|
16
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
17
|
+
import { resolveOrgId } from './helpers.js';
|
|
18
|
+
import { buildBatchArchive, splitMarkdown, indexMarkdown, MAX_BATCH_NOTES } from './notesBatch.js';
|
|
19
|
+
import { emailToNote, extractTasks, promoteBody } from './notesBridges.js';
|
|
20
|
+
import { linkMention } from './notesLinks.js';
|
|
21
|
+
|
|
22
|
+
const SCOPES = ['org', 'user'];
|
|
23
|
+
const ARCHIVED = ['exclude', 'only', 'include'];
|
|
24
|
+
const IMPORT_FORMATS = ['evernote', 'obsidian', 'markdown', 'notion'];
|
|
25
|
+
const IMPORT_MAX_BYTES = 100 * 1024 * 1024;
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
|
|
28
|
+
// Attachments: namespace + size cap mirror src/components/notesq/lib/attachments.js
|
|
29
|
+
// (NAMESPACE, SOURCE_PLUGIN, MAX_ATTACHMENT_BYTES). The editor and the CLI must
|
|
30
|
+
// write the same reference shape or notes stop rendering their files.
|
|
31
|
+
const ATTACH_NAMESPACE = 'notesq';
|
|
32
|
+
const ATTACH_MAX_BYTES = 25 * 1024 * 1024;
|
|
33
|
+
const IMAGE_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' };
|
|
34
|
+
const ATTACH_REF = /!?\[([^\]]*)\]\((\/api\/orgs\/[^/)\s]+\/files\/([^/?#)\s]+))\)/g;
|
|
35
|
+
|
|
36
|
+
// NotesQ platform settings (collector flags). Short names are the CLI spelling.
|
|
37
|
+
const SETTING_ALIASES = {
|
|
38
|
+
'autosave-ms': 'NOTESQ_AUTOSAVE_MS',
|
|
39
|
+
'default-collection': 'NOTESQ_DEFAULT_COLLECTION',
|
|
40
|
+
'agent-edit-policy': 'NOTESQ_AGENT_EDIT_POLICY',
|
|
41
|
+
'revision-retention': 'NOTESQ_REVISION_RETENTION',
|
|
42
|
+
};
|
|
43
|
+
const OUTPUT_FORMATS = ['table', 'json', 'csv', 'quiet'];
|
|
44
|
+
|
|
45
|
+
const noteLink = (id) => `/notes?note=${id}`;
|
|
46
|
+
|
|
47
|
+
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
48
|
+
const localTz = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } };
|
|
49
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
50
|
+
function describeCadence(x) {
|
|
51
|
+
const at = `${pad2(x.hour)}:${pad2(x.minute)} ${x.tz}`;
|
|
52
|
+
return x.cadence === 'weekly' ? `weekly ${WEEKDAYS[x.weekday] || '?'} ${at}` : `daily ${at}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Same rule as duplicateMarkdown in src/components/notesq/NotesQRail.jsx (mirrored, not imported: the CLI ships without src). */
|
|
56
|
+
function duplicateMarkdown(md) {
|
|
57
|
+
const text = String(md || '');
|
|
58
|
+
const m = /^\s*#\s+(.+)$/m.exec(text);
|
|
59
|
+
if (m && text.trimStart().startsWith('# ')) return text.replace(m[0], () => `# ${m[1].trim()} (copy)`);
|
|
60
|
+
return `# Untitled (copy)\n\n${text}`;
|
|
61
|
+
}
|
|
62
|
+
// Bookkeeping that belongs to the original, not the copy (NOT_COPIED in NotesQRail.jsx).
|
|
63
|
+
const NOT_COPIED = new Set(['import_ref', 'source', 'pinned', 'batch_id', 'batch_session']);
|
|
64
|
+
|
|
65
|
+
function formatBytes(n) {
|
|
66
|
+
const b = Number(n);
|
|
67
|
+
if (!Number.isFinite(b) || b < 0) return '0 B';
|
|
68
|
+
if (b < 1024) return `${Math.round(b)} B`;
|
|
69
|
+
const units = ['KB', 'MB', 'GB'];
|
|
70
|
+
let v = b / 1024;
|
|
71
|
+
let i = 0;
|
|
72
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
73
|
+
const t = v >= 10 || Number.isInteger(v) ? String(Math.round(v)) : v.toFixed(1);
|
|
74
|
+
return `${t} ${units[i]}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const refLabel = (s) => String(s || 'file').replace(/[[\]\\]/g, '_').replace(/[\r\n]+/g, ' ').trim() || 'file';
|
|
78
|
+
|
|
79
|
+
const safeName = (s, fallback) => {
|
|
80
|
+
const t = [...String(s || '')].map((ch) => (ch.charCodeAt(0) < 32 || '\\/:*?"<>|'.includes(ch) ? ' ' : ch)).join('').replace(/\s+/g, ' ').trim().slice(0, 80).trim();
|
|
81
|
+
return t || fallback;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Where to write a file: --out, or a -o value that is not an output format (the root -o swallows it). */
|
|
85
|
+
function targetPath(program, options) {
|
|
86
|
+
if (options.out) return options.out;
|
|
87
|
+
const g = program.opts().output;
|
|
88
|
+
return g && !OUTPUT_FORMATS.includes(g) ? g : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const collect = (v, prev) => (prev || []).concat(v);
|
|
92
|
+
const isJson = (program) => program.opts().output === 'json';
|
|
93
|
+
const printJson = (d) => console.log(JSON.stringify(d, null, 2));
|
|
94
|
+
const announce = (verb, result) => printEvent(glyphLine('brain2', verb, { result }));
|
|
95
|
+
|
|
96
|
+
function checkEnum(value, allowed, flag) {
|
|
97
|
+
if (value !== undefined && !allowed.includes(value)) {
|
|
98
|
+
throw new QuoxError(`${flag} must be one of: ${allowed.join(', ')}`, 2);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readStdin() {
|
|
103
|
+
const chunks = [];
|
|
104
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
105
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readContent(options) {
|
|
109
|
+
if (options.file && options.stdin) throw new QuoxError('Use --file or --stdin, not both.', 2);
|
|
110
|
+
if (options.file) return readFileSync(options.file, 'utf8');
|
|
111
|
+
if (options.stdin) return readStdin();
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Map NotesQ-specific statuses to actionable errors; everything else passes through. */
|
|
116
|
+
async function call(fn, { forbidden } = {}) {
|
|
117
|
+
try {
|
|
118
|
+
return await fn();
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (e instanceof QuoxApiError) {
|
|
121
|
+
if (e.status === 402) throw new QuoxError('NotesQ is not licensed for this org.', 1);
|
|
122
|
+
if (e.status === 403 && forbidden) throw new QuoxError(forbidden, 1);
|
|
123
|
+
}
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const shortId = (id) => String(id || '');
|
|
129
|
+
const when = (n) => n.updated_at || n.updatedAt || '';
|
|
130
|
+
|
|
131
|
+
export function registerNotesCommands(program, ctx) {
|
|
132
|
+
const { client, auth } = ctx;
|
|
133
|
+
const scorecardPath = ctx.scorecardPath
|
|
134
|
+
|| resolve(dirname(fileURLToPath(import.meta.url)), '../../../scripts/notesq-scorecard.mjs');
|
|
135
|
+
|
|
136
|
+
const getNote = async (id) => {
|
|
137
|
+
const gep = resolveEndpoint('notesq.notes.get', { id });
|
|
138
|
+
return (await call(() => client.get(gep.path, { service: gep.service }))).note || {};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** PUT with optimistic concurrency. A stale base is a 409 and is never retried. */
|
|
142
|
+
const putNote = async (id, body) => {
|
|
143
|
+
const pep = resolveEndpoint('notesq.notes.update', { id });
|
|
144
|
+
try {
|
|
145
|
+
return await call(() => client.put(pep.path, { service: pep.service, body }));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
if (e instanceof QuoxApiError && e.status === 409) {
|
|
148
|
+
const h = e.body && e.body.current_hash;
|
|
149
|
+
throw new QuoxError(`Edit conflict: note ${id} changed since you read it (current_hash ${h || 'unknown'}). Nothing was written. Re-run \`quox notes show ${id}\`, merge, and edit again.`, 1);
|
|
150
|
+
}
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const notes = program
|
|
156
|
+
.command('notes')
|
|
157
|
+
.description('NotesQ: governed markdown notes (list, read, write, history, restore)');
|
|
158
|
+
|
|
159
|
+
const list = async (options, textQuery) => {
|
|
160
|
+
auth.requireAuth();
|
|
161
|
+
checkEnum(options.scope, [...SCOPES, 'all'], '--scope');
|
|
162
|
+
checkEnum(options.archived, ARCHIVED, '--archived');
|
|
163
|
+
const query = {};
|
|
164
|
+
if (options.scope) query.scope = options.scope;
|
|
165
|
+
const q = textQuery ?? options.q;
|
|
166
|
+
if (q) query.q = q;
|
|
167
|
+
if (options.collection) query.collection = options.collection;
|
|
168
|
+
if (options.tag) query.tag = options.tag;
|
|
169
|
+
if (options.pinned) query.pinned = 'true';
|
|
170
|
+
if (options.archived) query.archived = options.archived;
|
|
171
|
+
if (options.limit !== undefined) query.limit = Number(options.limit);
|
|
172
|
+
const ep = resolveEndpoint('notesq.notes.list');
|
|
173
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
174
|
+
if (isJson(program)) return printJson(data);
|
|
175
|
+
const propKeys = String(options.props || '').split(',').map((k) => k.trim()).filter(Boolean);
|
|
176
|
+
const propText = (v) => {
|
|
177
|
+
if (v === null || v === undefined) return '';
|
|
178
|
+
if (Array.isArray(v)) return v.map((x) => (x !== null && typeof x === 'object' ? '...' : String(x))).join(',');
|
|
179
|
+
return typeof v === 'object' ? '...' : String(v);
|
|
180
|
+
};
|
|
181
|
+
const rows = (data.notes || []).map((n) => ({
|
|
182
|
+
...Object.fromEntries(propKeys.map((k, i) => [`prop${i}`, propText(n.properties && n.properties[k])])),
|
|
183
|
+
id: shortId(n.id),
|
|
184
|
+
title: n.title || '',
|
|
185
|
+
scope: n.scope || '',
|
|
186
|
+
collection: (n.properties && n.properties.collection) || n.collection || '',
|
|
187
|
+
tags: (n.tags || (n.properties && n.properties.tags) || []).join(','),
|
|
188
|
+
updated: when(n),
|
|
189
|
+
}));
|
|
190
|
+
formatOutput({
|
|
191
|
+
columns: ['id', 'title', 'scope', 'collection', 'tags', 'updated', ...propKeys.map((_, i) => `prop${i}`)],
|
|
192
|
+
headers: ['ID', 'Title', 'Scope', 'Collection', 'Tags', 'Updated', ...propKeys],
|
|
193
|
+
rows,
|
|
194
|
+
}, { format: program.opts().output });
|
|
195
|
+
announce('notes ls', `${rows.length} of ${data.total ?? rows.length}`);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const listOpts = (cmd) => cmd
|
|
199
|
+
.option('--scope <scope>', 'org | user | all (default all)')
|
|
200
|
+
.option('--collection <c>', 'filter by collection')
|
|
201
|
+
.option('--tag <t>', 'filter by tag')
|
|
202
|
+
.option('--pinned', 'only pinned notes')
|
|
203
|
+
.option('--archived <mode>', 'exclude (default) | only | include')
|
|
204
|
+
.option('--limit <n>', 'max notes (server caps at 200)')
|
|
205
|
+
.option('--props <keys>', 'extra property columns for table output, comma separated (e.g. status,owner); JSON output is unchanged');
|
|
206
|
+
|
|
207
|
+
listOpts(notes.command('ls').description('List notes'))
|
|
208
|
+
.option('--q <text>', 'full-text filter')
|
|
209
|
+
.action((options) => list(options));
|
|
210
|
+
|
|
211
|
+
listOpts(notes.command('search').description('Search notes (same as ls --q)'))
|
|
212
|
+
.argument('<text>', 'search text')
|
|
213
|
+
.action((text, options) => list(options, text));
|
|
214
|
+
|
|
215
|
+
notes
|
|
216
|
+
.command('show')
|
|
217
|
+
.description('Show a note: title, properties, markdown (--raw: full body incl. frontmatter)')
|
|
218
|
+
.argument('<id>', 'note id (full id, see `quox notes ls --json`)')
|
|
219
|
+
.option('--raw', 'print the full raw body including frontmatter')
|
|
220
|
+
.action(async (id, options) => {
|
|
221
|
+
auth.requireAuth();
|
|
222
|
+
const ep = resolveEndpoint('notesq.notes.get', { id });
|
|
223
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }));
|
|
224
|
+
if (isJson(program)) return printJson({ ...data, link: noteLink(id) });
|
|
225
|
+
const n = data.note || {};
|
|
226
|
+
if (options.raw) { console.log(n.body ?? ''); return; }
|
|
227
|
+
formatDetail({ id: n.id, title: n.title, scope: n.scope, ...(n.properties || {}), link: noteLink(n.id || id) });
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log(n.markdown ?? '');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
notes
|
|
233
|
+
.command('new')
|
|
234
|
+
.description('Create a note. Markdown from --file or --stdin, else "# <title>"')
|
|
235
|
+
.option('--scope <scope>', 'org | user (default org)', 'org')
|
|
236
|
+
.option('--title <t>', 'title (seeds a heading when no --file/--stdin)')
|
|
237
|
+
.option('--collection <c>', 'collection property')
|
|
238
|
+
.option('--tag <t>', 'tag (repeatable)', collect)
|
|
239
|
+
.option('--file <path>', 'read markdown from a file')
|
|
240
|
+
.option('--stdin', 'read markdown from stdin')
|
|
241
|
+
.option('--template <name>', 'start from a template (see `quox notes templates`); {{date}} etc. filled in your local time zone')
|
|
242
|
+
.action(async (options) => {
|
|
243
|
+
auth.requireAuth();
|
|
244
|
+
checkEnum(options.scope, SCOPES, '--scope');
|
|
245
|
+
if (options.template) {
|
|
246
|
+
if (options.file || options.stdin) throw new QuoxError('Use --template or --file/--stdin, not both.', 2);
|
|
247
|
+
const tep = resolveEndpoint('notesq.notes.fromTemplate');
|
|
248
|
+
const body = { template: options.template, scope: options.scope, tz: localTz() };
|
|
249
|
+
if (options.collection) body.collection = options.collection;
|
|
250
|
+
if (options.title) body.title = options.title;
|
|
251
|
+
const data = await call(() => client.post(tep.path, { service: tep.service, body }));
|
|
252
|
+
if (isJson(program)) return printJson(data);
|
|
253
|
+
const n = data.note || {};
|
|
254
|
+
announce('new', `"${n.title || options.template}" from template ${options.template}`);
|
|
255
|
+
return console.log(`link: ${noteLink(n.id)}`);
|
|
256
|
+
}
|
|
257
|
+
let markdown = await readContent(options);
|
|
258
|
+
if (markdown === null) markdown = `# ${options.title || 'Untitled'}\n\n`;
|
|
259
|
+
const properties = {};
|
|
260
|
+
if (options.collection) properties.collection = options.collection;
|
|
261
|
+
if (options.tag) properties.tags = options.tag;
|
|
262
|
+
const ep = resolveEndpoint('notesq.notes.create');
|
|
263
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { scope: options.scope, markdown, properties } }));
|
|
264
|
+
if (isJson(program)) return printJson(data);
|
|
265
|
+
const n = data.note || {};
|
|
266
|
+
printSuccess(`Created note ${n.id} (${n.title || 'untitled'}).`);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
notes
|
|
270
|
+
.command('edit')
|
|
271
|
+
.description('Replace a note\'s markdown (--file or --stdin). Fails with a conflict if the note changed since it was read.')
|
|
272
|
+
.argument('<id>', 'note id')
|
|
273
|
+
.option('--file <path>', 'read markdown from a file')
|
|
274
|
+
.option('--stdin', 'read markdown from stdin')
|
|
275
|
+
.option('--collection <c>', 'set collection property')
|
|
276
|
+
.option('--tag <t>', 'set tags (repeatable; replaces existing tags)', collect)
|
|
277
|
+
.action(async (id, options) => {
|
|
278
|
+
auth.requireAuth();
|
|
279
|
+
const markdown = await readContent(options);
|
|
280
|
+
if (markdown === null) throw new QuoxError('edit needs --file <path> or --stdin.', 2);
|
|
281
|
+
const cur = await getNote(id);
|
|
282
|
+
const properties = { ...(cur.properties || {}) };
|
|
283
|
+
if (options.collection) properties.collection = options.collection;
|
|
284
|
+
if (options.tag) properties.tags = options.tag;
|
|
285
|
+
const data = await putNote(id, { markdown, properties, base_hash: cur.content_hash });
|
|
286
|
+
if (isJson(program)) return printJson(data);
|
|
287
|
+
printSuccess(data.unchanged ? `Note ${id} unchanged.` : `Updated note ${id} (rev ${data.revision?.rev ?? '?'}).`);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
notes
|
|
291
|
+
.command('facets')
|
|
292
|
+
.description('Collections and tags with counts')
|
|
293
|
+
.option('--scope <scope>', 'org | user | all (default all)')
|
|
294
|
+
.action(async (options) => {
|
|
295
|
+
auth.requireAuth();
|
|
296
|
+
checkEnum(options.scope, [...SCOPES, 'all'], '--scope');
|
|
297
|
+
const ep = resolveEndpoint('notesq.facets');
|
|
298
|
+
const query = options.scope ? { scope: options.scope } : {};
|
|
299
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
300
|
+
if (isJson(program)) return printJson(data);
|
|
301
|
+
const rows = [
|
|
302
|
+
...(data.collections || []).map((c) => ({ kind: 'collection', name: c.name, count: c.count })),
|
|
303
|
+
...(data.tags || []).map((t) => ({ kind: 'tag', name: t.tag, count: t.count })),
|
|
304
|
+
];
|
|
305
|
+
formatOutput({ columns: ['kind', 'name', 'count'], headers: ['Kind', 'Name', 'Count'], rows }, { format: program.opts().output });
|
|
306
|
+
console.log(`total ${data.total ?? 0} | pinned ${data.pinned ?? 0} | archived ${data.archived ?? 0}`);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
notes
|
|
310
|
+
.command('history')
|
|
311
|
+
.description('Revision history of a note')
|
|
312
|
+
.argument('<id>', 'note id')
|
|
313
|
+
.action(async (id) => {
|
|
314
|
+
auth.requireAuth();
|
|
315
|
+
const ep = resolveEndpoint('notesq.notes.revisions', { id });
|
|
316
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }));
|
|
317
|
+
if (isJson(program)) return printJson(data);
|
|
318
|
+
formatOutput({
|
|
319
|
+
columns: ['rev', 'action', 'author_kind', 'content_hash', 'created_at'],
|
|
320
|
+
headers: ['Rev', 'Action', 'Author', 'Hash', 'When'],
|
|
321
|
+
rows: data.revisions || [],
|
|
322
|
+
}, { format: program.opts().output });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
notes
|
|
326
|
+
.command('restore')
|
|
327
|
+
.description('Restore a note to an earlier revision (writes a new revision)')
|
|
328
|
+
.argument('<id>', 'note id')
|
|
329
|
+
.requiredOption('--rev <n>', 'revision number to restore')
|
|
330
|
+
.action(async (id, options) => {
|
|
331
|
+
auth.requireAuth();
|
|
332
|
+
const rev = Number(options.rev);
|
|
333
|
+
if (!Number.isInteger(rev) || rev < 1) throw new QuoxError('--rev must be a positive integer.', 2);
|
|
334
|
+
const ep = resolveEndpoint('notesq.notes.restore', { id });
|
|
335
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { rev } }), { forbidden: 'You do not have write access to this note.' });
|
|
336
|
+
if (isJson(program)) return printJson(data);
|
|
337
|
+
printSuccess(data.unchanged ? `Note ${id} already matches rev ${rev}.` : `Restored note ${id} to rev ${rev} (new rev ${data.revision?.rev ?? '?'}).`);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
notes
|
|
341
|
+
.command('rm')
|
|
342
|
+
.description('Delete a note (author or org admin only). Requires --yes. --purge permanently erases the note and every revision (human only, irreversible).')
|
|
343
|
+
.argument('<id>', 'note id')
|
|
344
|
+
.option('--yes', 'confirm deletion')
|
|
345
|
+
.option('--purge', 'permanently erase the note and every revision; human sessions only, cannot be undone, requires --yes')
|
|
346
|
+
.action(async (id, options) => {
|
|
347
|
+
auth.requireAuth();
|
|
348
|
+
if (!options.yes) throw new QuoxError(`Refusing to ${options.purge ? 'purge' : 'delete'} ${id} without --yes.`, 2);
|
|
349
|
+
const ep = resolveEndpoint('notesq.notes.delete', { id });
|
|
350
|
+
const opts = { service: ep.service };
|
|
351
|
+
if (options.purge) opts.query = { purge: 'true' };
|
|
352
|
+
const forbidden = options.purge
|
|
353
|
+
? 'Purge refused: only the author or an org admin can purge a note, and only from a human session.'
|
|
354
|
+
: 'Only the author or an org admin can delete this note.';
|
|
355
|
+
const data = await call(() => client.delete(ep.path, opts), { forbidden });
|
|
356
|
+
if (isJson(program)) return printJson(data);
|
|
357
|
+
printSuccess(options.purge ? `Purged note ${id} and all its revisions.` : `Deleted note ${id}.`);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
notes
|
|
361
|
+
.command('related')
|
|
362
|
+
.description('Notes related to this one (lexical until embeddings are backfilled, then hybrid)')
|
|
363
|
+
.argument('<id>', 'note id')
|
|
364
|
+
.option('--limit <n>', 'max results')
|
|
365
|
+
.action(async (id, options) => {
|
|
366
|
+
auth.requireAuth();
|
|
367
|
+
const ep = resolveEndpoint('notesq.notes.related', { id });
|
|
368
|
+
const query = options.limit !== undefined ? { limit: Number(options.limit) } : {};
|
|
369
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
370
|
+
if (isJson(program)) return printJson(data);
|
|
371
|
+
formatOutput({
|
|
372
|
+
columns: ['id', 'title', 'score'],
|
|
373
|
+
headers: ['ID', 'Title', 'Score'],
|
|
374
|
+
rows: (data.related || []).map((r) => ({ id: r.id, title: r.title || '', score: r.score ?? '' })),
|
|
375
|
+
}, { format: program.opts().output });
|
|
376
|
+
console.log(`mode: ${data.mode || 'lexical'}`);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
notes
|
|
380
|
+
.command('suggest')
|
|
381
|
+
.description('Link suggestions for a note: other notes whose title appears as plain text, then related notes. --link <targetId> turns the first mention into a [[wikilink]]')
|
|
382
|
+
.argument('<id>', 'note id')
|
|
383
|
+
.option('--link <targetId>', 'rewrite the first plain mention of that note\'s title into a [[wikilink]]')
|
|
384
|
+
.option('--limit <n>', 'max results per list (1-20)')
|
|
385
|
+
.action(async (id, options) => {
|
|
386
|
+
auth.requireAuth();
|
|
387
|
+
const ep = resolveEndpoint('notesq.notes.suggestions', { id });
|
|
388
|
+
const query = options.limit !== undefined ? { limit: Number(options.limit) } : {};
|
|
389
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
390
|
+
if (options.link) {
|
|
391
|
+
const mention = (data.mentions || []).find((m) => m.id === options.link);
|
|
392
|
+
if (!mention) throw new QuoxError(`${options.link} is not a current mention in this note. Run \`quox notes suggest ${id}\` to see them.`, 2);
|
|
393
|
+
const target = await getNote(options.link);
|
|
394
|
+
const cur = await getNote(id);
|
|
395
|
+
const r = linkMention(cur.markdown ?? '', target.title || mention.title);
|
|
396
|
+
if (!r.changed) throw new QuoxError(`No plain-text mention of "${target.title || mention.title}" left to link.`, 2);
|
|
397
|
+
const out = await putNote(id, { markdown: r.markdown, properties: { ...(cur.properties || {}) }, base_hash: cur.content_hash });
|
|
398
|
+
if (isJson(program)) return printJson(out);
|
|
399
|
+
announce('suggest', `linked [[${target.title || mention.title}]]`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (isJson(program)) return printJson(data);
|
|
403
|
+
const mentions = data.mentions || [];
|
|
404
|
+
const related = data.related || [];
|
|
405
|
+
console.log(`Mentions (${mentions.length}): titles that appear as plain text`);
|
|
406
|
+
formatOutput({
|
|
407
|
+
columns: ['id', 'title', 'count', 'snippet'],
|
|
408
|
+
headers: ['ID', 'Title', 'Count', 'Snippet'],
|
|
409
|
+
rows: mentions.map((m) => ({ id: m.id, title: m.title || '', count: m.count, snippet: m.snippet || '' })),
|
|
410
|
+
}, { format: program.opts().output });
|
|
411
|
+
console.log(`Related (${related.length}), mode: ${data.mode || 'lexical'}`);
|
|
412
|
+
formatOutput({
|
|
413
|
+
columns: ['id', 'title', 'score'],
|
|
414
|
+
headers: ['ID', 'Title', 'Score'],
|
|
415
|
+
rows: related.map((r) => ({ id: r.id, title: r.title || '', score: r.score ?? '' })),
|
|
416
|
+
}, { format: program.opts().output });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
notes
|
|
420
|
+
.command('health')
|
|
421
|
+
.description('Notebook health: duplicate groups, stale notes others rely on, and broken [[links]]')
|
|
422
|
+
.option('--scope <scope>', 'org|user (default org)')
|
|
423
|
+
.option('--stale-days <n>', 'age in days before a linked-to note counts as stale (30-3650, default 180)')
|
|
424
|
+
.action(async (options) => {
|
|
425
|
+
auth.requireAuth();
|
|
426
|
+
checkEnum(options.scope, SCOPES, '--scope');
|
|
427
|
+
const ep = resolveEndpoint('notesq.health');
|
|
428
|
+
const query = {};
|
|
429
|
+
if (options.scope) query.scope = options.scope;
|
|
430
|
+
if (options.staleDays !== undefined) query.stale_days = Number(options.staleDays);
|
|
431
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
432
|
+
if (isJson(program)) return printJson(data);
|
|
433
|
+
const fmt = program.opts().output;
|
|
434
|
+
const dups = data.duplicates || [];
|
|
435
|
+
const stale = data.stale || [];
|
|
436
|
+
const broken = data.broken_links || [];
|
|
437
|
+
console.log(`Scanned ${data.scanned} note(s) in ${data.scope} scope${data.truncated ? ' (truncated)' : ''}`);
|
|
438
|
+
console.log(`Duplicates (${dups.length})`);
|
|
439
|
+
formatOutput({
|
|
440
|
+
columns: ['kind', 'similarity', 'notes'],
|
|
441
|
+
headers: ['Kind', 'Similarity', 'Notes'],
|
|
442
|
+
rows: dups.map((g) => ({ kind: g.kind, similarity: g.similarity ?? '', notes: g.notes.map((n) => `${n.title} (${n.id})`).join('; ') })),
|
|
443
|
+
}, { format: fmt });
|
|
444
|
+
console.log(`Stale but linked to (${stale.length})`);
|
|
445
|
+
formatOutput({
|
|
446
|
+
columns: ['id', 'title', 'updated_at', 'backlinks'],
|
|
447
|
+
headers: ['ID', 'Title', 'Updated', 'Backlinks'],
|
|
448
|
+
rows: stale.map((s) => ({ id: s.id, title: s.title || '', updated_at: s.updated_at || '', backlinks: s.backlinks })),
|
|
449
|
+
}, { format: fmt });
|
|
450
|
+
console.log(`Broken links (${broken.length})`);
|
|
451
|
+
formatOutput({
|
|
452
|
+
columns: ['source', 'target', 'count'],
|
|
453
|
+
headers: ['Source', 'Target', 'Count'],
|
|
454
|
+
rows: broken.map((b) => ({ source: `${b.source.title} (${b.source.id})`, target: b.target_raw, count: b.count })),
|
|
455
|
+
}, { format: fmt });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
notes
|
|
459
|
+
.command('import')
|
|
460
|
+
.description('Import notes from an Evernote export (.enex), an Obsidian vault (.zip) or markdown (.md/.zip)')
|
|
461
|
+
.argument('<file>', 'file to import (max 100 MB)')
|
|
462
|
+
.option('--format <format>', `source format: ${IMPORT_FORMATS.join('|')} (default: detect from extension)`)
|
|
463
|
+
.option('--scope <scope>', 'org|user (default org)')
|
|
464
|
+
.option('--collection <name>', 'put every imported note in this collection')
|
|
465
|
+
.option('--dry-run', 'parse only and print what would be imported; writes nothing')
|
|
466
|
+
.addOption(new Option('--poll-ms <n>', 'job poll interval').default('2000').hideHelp())
|
|
467
|
+
.action(async (file, options) => {
|
|
468
|
+
auth.requireAuth();
|
|
469
|
+
checkEnum(options.format, IMPORT_FORMATS, '--format');
|
|
470
|
+
checkEnum(options.scope, SCOPES, '--scope');
|
|
471
|
+
let bytes;
|
|
472
|
+
try { bytes = readFileSync(file); } catch (e) { throw new QuoxError(`Cannot read ${file}: ${e.message}`, 2); }
|
|
473
|
+
if (bytes.length > IMPORT_MAX_BYTES) throw new QuoxError(`${file} is over the 100 MB import limit.`, 2);
|
|
474
|
+
const query = {};
|
|
475
|
+
if (options.format) query.format = options.format;
|
|
476
|
+
if (options.scope) query.scope = options.scope;
|
|
477
|
+
if (options.collection) query.collection = options.collection;
|
|
478
|
+
if (options.dryRun) query.dry_run = 'true';
|
|
479
|
+
const form = new FormData();
|
|
480
|
+
form.append('file', new Blob([bytes]), basename(file));
|
|
481
|
+
const ep = resolveEndpoint('notesq.import');
|
|
482
|
+
// retries: 0, a retried multipart upload would start a second job.
|
|
483
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: form, query, timeout: 300000, retries: 0 }));
|
|
484
|
+
|
|
485
|
+
if (options.dryRun) {
|
|
486
|
+
if (isJson(program)) return printJson(data);
|
|
487
|
+
console.log(`Dry run (${data.format}): ${data.notes} notes, ${data.attachments} attachments. Nothing written.`);
|
|
488
|
+
formatOutput({
|
|
489
|
+
columns: ['collection', 'notes'],
|
|
490
|
+
headers: ['Collection', 'Notes'],
|
|
491
|
+
rows: Object.entries(data.collections || {}).map(([collection, n]) => ({ collection, notes: n })),
|
|
492
|
+
}, { format: program.opts().output });
|
|
493
|
+
if ((data.tags || []).length) console.log(`tags: ${data.tags.join(', ')}`);
|
|
494
|
+
if ((data.sample || []).length) console.log(`sample: ${data.sample.join(' | ')}`);
|
|
495
|
+
for (const w of data.warnings || []) console.log(`warning: ${w}`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!data.job_id) throw new QuoxError('Import did not return a job id.', 1);
|
|
500
|
+
const sp = resolveEndpoint('notesq.import.status', { jobId: data.job_id });
|
|
501
|
+
const pollMs = Math.max(Number(options.pollMs) || 2000, 0);
|
|
502
|
+
const json = isJson(program);
|
|
503
|
+
// Say the job id up front: the import keeps running server-side if this terminal goes away.
|
|
504
|
+
if (!json) console.log(`import job ${data.job_id} started (check any time: quox notes import-status ${data.job_id})`);
|
|
505
|
+
let st;
|
|
506
|
+
let last = '';
|
|
507
|
+
for (;;) {
|
|
508
|
+
st = await call(() => client.get(sp.path, { service: sp.service }));
|
|
509
|
+
const progress = `${st.created + st.skipped + st.failed}/${st.total} (created ${st.created}, skipped ${st.skipped}, failed ${st.failed})`;
|
|
510
|
+
if (!json && st.status === 'running' && progress !== last) { console.log(`importing ${progress}`); last = progress; }
|
|
511
|
+
if (st.status !== 'running') break;
|
|
512
|
+
await sleep(pollMs);
|
|
513
|
+
}
|
|
514
|
+
if (json) printJson({ job_id: data.job_id, ...st });
|
|
515
|
+
else {
|
|
516
|
+
announce('import', `${st.created} created, ${st.skipped} skipped, ${st.failed} failed`);
|
|
517
|
+
for (const e of st.errors || []) console.log(`failed: ${e.title}: ${e.error}`);
|
|
518
|
+
}
|
|
519
|
+
if (st.status === 'failed') throw new QuoxError(`Import failed: ${st.error || 'unknown error'}`, 1);
|
|
520
|
+
if (st.failed > 0) throw new QuoxError(`${st.failed} note(s) failed to import.`, 1);
|
|
521
|
+
if (!json) printSuccess(`Imported ${st.created} notes (${st.skipped} already present).`);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
notes
|
|
525
|
+
.command('tag')
|
|
526
|
+
.description('Change a note\'s tags, collection, pinned or archived state without touching its text')
|
|
527
|
+
.argument('<id>', 'note id')
|
|
528
|
+
.option('--add <tag>', 'add a tag (repeatable)', collect)
|
|
529
|
+
.option('--remove <tag>', 'remove a tag (repeatable)', collect)
|
|
530
|
+
.option('--collection <c>', 'set the collection')
|
|
531
|
+
.option('--no-collection', 'clear the collection')
|
|
532
|
+
.option('--pin', 'pin the note')
|
|
533
|
+
.option('--unpin', 'unpin the note')
|
|
534
|
+
.option('--archive', 'archive the note')
|
|
535
|
+
.option('--unarchive', 'restore the note from the archive')
|
|
536
|
+
.action(async (id, options) => {
|
|
537
|
+
auth.requireAuth();
|
|
538
|
+
if (options.pin && options.unpin) throw new QuoxError('Use --pin or --unpin, not both.', 2);
|
|
539
|
+
if (options.archive && options.unarchive) throw new QuoxError('Use --archive or --unarchive, not both.', 2);
|
|
540
|
+
const changes = [options.add, options.remove, options.collection !== undefined, options.pin, options.unpin, options.archive, options.unarchive];
|
|
541
|
+
if (!changes.some(Boolean)) throw new QuoxError('Nothing to change: pass --add, --remove, --collection, --no-collection, --pin, --unpin, --archive or --unarchive.', 2);
|
|
542
|
+
const cur = await getNote(id);
|
|
543
|
+
const properties = { ...(cur.properties || {}) };
|
|
544
|
+
if (options.add || options.remove) {
|
|
545
|
+
const drop = new Set(options.remove || []);
|
|
546
|
+
const tags = [...(properties.tags || []), ...(options.add || [])].filter((t) => !drop.has(t));
|
|
547
|
+
properties.tags = [...new Set(tags)];
|
|
548
|
+
}
|
|
549
|
+
if (options.collection === false) delete properties.collection;
|
|
550
|
+
else if (options.collection) properties.collection = options.collection;
|
|
551
|
+
if (options.pin) properties.pinned = true;
|
|
552
|
+
if (options.unpin) properties.pinned = false;
|
|
553
|
+
if (options.archive) properties.archived = true;
|
|
554
|
+
if (options.unarchive) properties.archived = false;
|
|
555
|
+
const data = await putNote(id, { markdown: cur.markdown ?? '', properties, base_hash: cur.content_hash });
|
|
556
|
+
if (isJson(program)) return printJson(data);
|
|
557
|
+
console.log(`tags: ${(properties.tags || []).join(', ') || '(none)'}`);
|
|
558
|
+
console.log(`collection: ${properties.collection || '(none)'}`);
|
|
559
|
+
console.log(`pinned: ${properties.pinned ? 'yes' : 'no'} | archived: ${properties.archived ? 'yes' : 'no'}`);
|
|
560
|
+
announce('notes tag', data.unchanged ? 'unchanged' : `rev ${data.revision?.rev ?? '?'}`);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
notes
|
|
564
|
+
.command('append')
|
|
565
|
+
.description('Append text to the end of a note (blank line between). Text from the argument, --stdin or --file.')
|
|
566
|
+
.argument('<id>', 'note id')
|
|
567
|
+
.argument('[text]', 'text to append')
|
|
568
|
+
.option('--stdin', 'read the text from stdin')
|
|
569
|
+
.option('--file <path>', 'read the text from a file')
|
|
570
|
+
.action(async (id, text, options) => {
|
|
571
|
+
auth.requireAuth();
|
|
572
|
+
const fromFlags = await readContent(options);
|
|
573
|
+
if (text !== undefined && fromFlags !== null) throw new QuoxError('Give the text as an argument or use --stdin/--file, not both.', 2);
|
|
574
|
+
const addition = text !== undefined ? text : fromFlags;
|
|
575
|
+
if (addition === null || addition === undefined) throw new QuoxError('append needs text, --stdin or --file <path>.', 2);
|
|
576
|
+
if (!String(addition).trim()) throw new QuoxError('Nothing to append: the text is empty.', 2);
|
|
577
|
+
const cur = await getNote(id);
|
|
578
|
+
const markdown = `${String(cur.markdown ?? '').replace(/\s+$/, '')}\n\n${String(addition).replace(/\s+$/, '')}`;
|
|
579
|
+
const data = await putNote(id, { markdown, properties: { ...(cur.properties || {}) }, base_hash: cur.content_hash });
|
|
580
|
+
if (isJson(program)) return printJson(data);
|
|
581
|
+
printSuccess(`Appended to note ${id} (rev ${data.revision?.rev ?? '?'}).`);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
notes
|
|
585
|
+
.command('duplicate')
|
|
586
|
+
.description('Copy a note in the same scope as "<title> (copy)" (pinned, source and import_ref are not copied)')
|
|
587
|
+
.argument('<id>', 'note id')
|
|
588
|
+
.action(async (id) => {
|
|
589
|
+
auth.requireAuth();
|
|
590
|
+
const cur = await getNote(id);
|
|
591
|
+
const properties = Object.fromEntries(Object.entries(cur.properties || {}).filter(([k]) => !NOT_COPIED.has(k)));
|
|
592
|
+
const ep = resolveEndpoint('notesq.notes.create');
|
|
593
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { scope: cur.scope, markdown: duplicateMarkdown(cur.markdown), properties } }));
|
|
594
|
+
const n = data.note || {};
|
|
595
|
+
if (isJson(program)) return printJson({ ...data, link: noteLink(n.id) });
|
|
596
|
+
printSuccess(`Duplicated note ${id} as ${n.id}.`);
|
|
597
|
+
console.log(`link: ${noteLink(n.id)}`);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
notes
|
|
601
|
+
.command('attach')
|
|
602
|
+
.description('Upload a file (max 25 MB) to the org file store and append a reference to the note')
|
|
603
|
+
.argument('<id>', 'note id')
|
|
604
|
+
.argument('<file>', 'file to attach')
|
|
605
|
+
.action(async (id, file) => {
|
|
606
|
+
auth.requireAuth();
|
|
607
|
+
const orgId = resolveOrgId(auth, program);
|
|
608
|
+
const name = basename(file);
|
|
609
|
+
let size;
|
|
610
|
+
let bytes;
|
|
611
|
+
try { size = statSync(file).size; } catch (e) { throw new QuoxError(`Cannot read ${file}: ${e.message}`, 2); }
|
|
612
|
+
if (size === 0) throw new QuoxError(`${name} is empty.`, 2);
|
|
613
|
+
if (size > ATTACH_MAX_BYTES) throw new QuoxError(`${name} is ${formatBytes(size)}, over the ${formatBytes(ATTACH_MAX_BYTES)} attachment limit.`, 2);
|
|
614
|
+
try { bytes = readFileSync(file); } catch (e) { throw new QuoxError(`Cannot read ${file}: ${e.message}`, 2); }
|
|
615
|
+
await getNote(id); // fail on a missing or forbidden note before uploading anything
|
|
616
|
+
const ext = (/\.([A-Za-z0-9]+)$/.exec(name) || [])[1]?.toLowerCase() || '';
|
|
617
|
+
const mime = IMAGE_MIME[ext] || 'application/octet-stream';
|
|
618
|
+
const form = new FormData();
|
|
619
|
+
form.append('file', new Blob([bytes], { type: mime }), name);
|
|
620
|
+
form.append('namespace', ATTACH_NAMESPACE);
|
|
621
|
+
form.append('source_plugin', ATTACH_NAMESPACE);
|
|
622
|
+
form.append('source_ref', id);
|
|
623
|
+
const uep = resolveEndpoint('notesq.files.upload', { orgId });
|
|
624
|
+
// retries: 0, a retried multipart upload would store a second file row.
|
|
625
|
+
const up = await call(() => client.post(uep.path, { service: uep.service, body: form, timeout: 300000, retries: 0 }));
|
|
626
|
+
if (!up || !up.id) throw new QuoxError('Upload failed: the server returned no file id.', 1);
|
|
627
|
+
const url = `/api/orgs/${encodeURIComponent(orgId)}/files/${encodeURIComponent(up.id)}`;
|
|
628
|
+
const ref = IMAGE_MIME[ext] ? `` : `[${refLabel(name)} (${formatBytes(size)})](${url})`;
|
|
629
|
+
// Re-read after the upload so a slow transfer does not turn into a stale base_hash.
|
|
630
|
+
const cur = await getNote(id);
|
|
631
|
+
const markdown = `${String(cur.markdown ?? '').replace(/\s+$/, '')}\n\n${ref}`;
|
|
632
|
+
const data = await putNote(id, { markdown, properties: { ...(cur.properties || {}) }, base_hash: cur.content_hash });
|
|
633
|
+
if (isJson(program)) return printJson({ file: up, reference: ref, note: data.note });
|
|
634
|
+
printSuccess(`Attached ${name} to note ${id} (file ${up.id}).`);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
notes
|
|
638
|
+
.command('attachments')
|
|
639
|
+
.description('List the org files a note references (name, file id, url)')
|
|
640
|
+
.argument('<id>', 'note id')
|
|
641
|
+
.action(async (id) => {
|
|
642
|
+
auth.requireAuth();
|
|
643
|
+
const cur = await getNote(id);
|
|
644
|
+
const attachments = [];
|
|
645
|
+
for (const m of String(cur.markdown ?? '').matchAll(ATTACH_REF)) {
|
|
646
|
+
attachments.push({ name: m[1], file_id: decodeURIComponent(m[3]), url: m[2] });
|
|
647
|
+
}
|
|
648
|
+
if (isJson(program)) return printJson({ attachments });
|
|
649
|
+
formatOutput({
|
|
650
|
+
columns: ['name', 'file_id', 'url'],
|
|
651
|
+
headers: ['Name', 'File ID', 'URL'],
|
|
652
|
+
rows: attachments,
|
|
653
|
+
emptyMessage: 'No attachments.',
|
|
654
|
+
}, { format: program.opts().output });
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
notes
|
|
658
|
+
.command('download')
|
|
659
|
+
.description('Download an org file (by file id, see `quox notes attachments`). Never overwrites without --force.')
|
|
660
|
+
.argument('<fileId>', 'file id')
|
|
661
|
+
.option('--out <path>', 'write to this path (-o <path> also works); default: the original filename in the current directory')
|
|
662
|
+
.option('--force', 'overwrite an existing file')
|
|
663
|
+
.action(async (fileId, options) => {
|
|
664
|
+
auth.requireAuth();
|
|
665
|
+
const orgId = resolveOrgId(auth, program);
|
|
666
|
+
let target = targetPath(program, options);
|
|
667
|
+
if (!target) {
|
|
668
|
+
const mep = resolveEndpoint('notesq.files.meta', { orgId, fileId });
|
|
669
|
+
const meta = await call(() => client.get(mep.path, { service: mep.service }));
|
|
670
|
+
target = [...basename(String(meta.filename || fileId))].map((ch) => (ch.charCodeAt(0) < 32 ? '_' : ch)).join('') || fileId;
|
|
671
|
+
}
|
|
672
|
+
target = resolve(ctx.cwd || process.cwd(), target);
|
|
673
|
+
if (existsSync(target) && !options.force) throw new QuoxError(`${target} already exists. Use --force to overwrite.`, 1);
|
|
674
|
+
const gep = resolveEndpoint('notesq.files.get', { orgId, fileId });
|
|
675
|
+
const res = await call(() => client.get(gep.path, { service: gep.service, raw: true, timeout: 300000 }));
|
|
676
|
+
if (!res.ok) throw new QuoxError(`Download failed: HTTP ${res.status} for file ${fileId}.`, 1);
|
|
677
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
678
|
+
writeFileSync(target, buf);
|
|
679
|
+
if (isJson(program)) return printJson({ file_id: fileId, path: target, bytes: buf.length });
|
|
680
|
+
printSuccess(`Saved ${target} (${formatBytes(buf.length)}).`);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
notes
|
|
684
|
+
.command('export')
|
|
685
|
+
.description('Export notes as a zip of <collection or Unfiled>/<title> <id8>.md with frontmatter intact (archived notes included)')
|
|
686
|
+
.option('--scope <scope>', 'org | user | all (default all)')
|
|
687
|
+
.option('--collection <c>', 'only this collection')
|
|
688
|
+
.option('--tag <t>', 'only this tag')
|
|
689
|
+
.option('--out <path>', 'destination .zip (-o <path> also works)')
|
|
690
|
+
.option('--force', 'overwrite an existing destination')
|
|
691
|
+
.option('--with-attachments', 'also save every attached file into the export and point the notes at those copies (a complete offline copy)')
|
|
692
|
+
.action(async (options) => {
|
|
693
|
+
auth.requireAuth();
|
|
694
|
+
checkEnum(options.scope, [...SCOPES, 'all'], '--scope');
|
|
695
|
+
const out = targetPath(program, options);
|
|
696
|
+
if (!out) throw new QuoxError('export needs --out <file.zip>.', 2);
|
|
697
|
+
const target = resolve(out);
|
|
698
|
+
if (existsSync(target) && !options.force) throw new QuoxError(`${target} already exists. Use --force to overwrite.`, 1);
|
|
699
|
+
|
|
700
|
+
const PAGE = 200;
|
|
701
|
+
const lep = resolveEndpoint('notesq.notes.list');
|
|
702
|
+
const all = [];
|
|
703
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
704
|
+
const query = { limit: PAGE, offset, archived: 'include' };
|
|
705
|
+
if (options.scope) query.scope = options.scope;
|
|
706
|
+
if (options.collection) query.collection = options.collection;
|
|
707
|
+
if (options.tag) query.tag = options.tag;
|
|
708
|
+
const page = await call(() => client.get(lep.path, { service: lep.service, query }));
|
|
709
|
+
const rows = page.notes || [];
|
|
710
|
+
all.push(...rows);
|
|
711
|
+
if (rows.length === 0 || all.length >= (page.total ?? all.length)) break;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Fetch the raw body (frontmatter included), a few at a time.
|
|
715
|
+
const entries = [];
|
|
716
|
+
for (let i = 0; i < all.length; i += 8) {
|
|
717
|
+
const batch = await Promise.all(all.slice(i, i + 8).map(async (n) => {
|
|
718
|
+
const gep = resolveEndpoint('notesq.notes.get', { id: n.id });
|
|
719
|
+
const full = (await call(() => client.get(gep.path, { service: gep.service }))).note || {};
|
|
720
|
+
return { n, body: full.body ?? full.markdown ?? '' };
|
|
721
|
+
}));
|
|
722
|
+
entries.push(...batch);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const used = new Set();
|
|
726
|
+
const files = entries.map(({ n, body }) => {
|
|
727
|
+
const dir = safeName((n.properties && n.properties.collection) || n.collection, 'Unfiled');
|
|
728
|
+
const base = `${dir}/${safeName(n.title, 'Untitled')} ${String(n.id).slice(0, 8)}`;
|
|
729
|
+
const path = used.has(`${base}.md`) ? `${base} ${n.id}.md` : `${base}.md`;
|
|
730
|
+
used.add(path);
|
|
731
|
+
return { path, body };
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// --with-attachments: fetch each referenced file once (same org files route as
|
|
735
|
+
// `download`), store it under _attachments/, and rewrite the note's link to the
|
|
736
|
+
// relative copy, so the export reads offline. Without it, links still point at
|
|
737
|
+
// the server (they keep working while you can sign in).
|
|
738
|
+
let attachments = 0;
|
|
739
|
+
if (options.withAttachments) {
|
|
740
|
+
const orgId = resolveOrgId(auth, program);
|
|
741
|
+
const fetched = new Map();
|
|
742
|
+
for (const f of [...files]) { // snapshot: attachment files are appended as we go
|
|
743
|
+
const depth = f.path.split('/').length - 1;
|
|
744
|
+
const up = '../'.repeat(depth);
|
|
745
|
+
let body = f.body;
|
|
746
|
+
for (const m of [...String(body).matchAll(ATTACH_REF)]) {
|
|
747
|
+
const [whole, name, url, fileId] = m;
|
|
748
|
+
if (!fetched.has(fileId)) {
|
|
749
|
+
const gep = resolveEndpoint('notesq.files.get', { orgId, fileId });
|
|
750
|
+
const res = await call(() => client.get(gep.path, { service: gep.service, raw: true, timeout: 300000 }));
|
|
751
|
+
const local = `_attachments/${fileId}-${safeName(name, 'file')}`;
|
|
752
|
+
files.push({ path: local, body: Buffer.from(await res.arrayBuffer()), binary: true });
|
|
753
|
+
fetched.set(fileId, local);
|
|
754
|
+
attachments++;
|
|
755
|
+
}
|
|
756
|
+
body = body.split(whole).join(whole.replace(url, `${up}${fetched.get(fileId)}`));
|
|
757
|
+
}
|
|
758
|
+
f.body = body;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
let jszip = null;
|
|
763
|
+
try { jszip = (await import('jszip')).default; } catch { /* no zip library: fall back to a directory */ }
|
|
764
|
+
let written;
|
|
765
|
+
if (jszip) {
|
|
766
|
+
const zip = new jszip();
|
|
767
|
+
for (const f of files) zip.file(f.path, f.body, { createFolders: false });
|
|
768
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
769
|
+
writeFileSync(target, await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }));
|
|
770
|
+
written = target;
|
|
771
|
+
} else {
|
|
772
|
+
written = target.replace(/\.zip$/i, '');
|
|
773
|
+
for (const f of files) {
|
|
774
|
+
const p = join(written, f.path);
|
|
775
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
776
|
+
writeFileSync(p, f.body);
|
|
777
|
+
}
|
|
778
|
+
console.log(`No zip library is installed, so a directory was written instead: ${written}`);
|
|
779
|
+
}
|
|
780
|
+
const noteCount = files.filter((f) => !f.binary).length;
|
|
781
|
+
if (isJson(program)) return printJson({ notes: noteCount, attachments, path: written });
|
|
782
|
+
announce('notes export', `${noteCount} notes${attachments ? `, ${attachments} attachments` : ''}`);
|
|
783
|
+
printSuccess(`Exported ${noteCount} notes${attachments ? ` and ${attachments} attachments` : ''} to ${written}.`);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
notes
|
|
787
|
+
.command('import-status')
|
|
788
|
+
.description('Show an import job: status, counts and errors. Exits non-zero if the job or any note failed.')
|
|
789
|
+
.argument('<jobId>', 'job id printed by `quox notes import`')
|
|
790
|
+
.action(async (jobId) => {
|
|
791
|
+
auth.requireAuth();
|
|
792
|
+
const ep = resolveEndpoint('notesq.import.status', { jobId });
|
|
793
|
+
const st = await call(() => client.get(ep.path, { service: ep.service }));
|
|
794
|
+
if (isJson(program)) printJson(st);
|
|
795
|
+
else {
|
|
796
|
+
console.log(`status: ${st.status}`);
|
|
797
|
+
console.log(`${st.created} created, ${st.skipped} skipped, ${st.failed} failed of ${st.total}`);
|
|
798
|
+
for (const e of st.errors || []) console.log(`failed: ${e.title}: ${e.error}`);
|
|
799
|
+
}
|
|
800
|
+
if (st.status === 'failed') throw new QuoxError(`Import failed: ${st.error || 'unknown error'}`, 1);
|
|
801
|
+
if (st.failed > 0) throw new QuoxError(`${st.failed} note(s) failed to import.`, 1);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// ---- templates + scheduled notes (AQ-9D P4) -----------------------------
|
|
805
|
+
notes
|
|
806
|
+
.command('templates')
|
|
807
|
+
.description('List note templates: built-ins (daily, weekly, meeting, decision) and your notes marked as templates (property template: true)')
|
|
808
|
+
.action(async () => {
|
|
809
|
+
auth.requireAuth();
|
|
810
|
+
const ep = resolveEndpoint('notesq.templates.list');
|
|
811
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }));
|
|
812
|
+
if (isJson(program)) return printJson(data);
|
|
813
|
+
formatOutput({
|
|
814
|
+
columns: ['name', 'source', 'id'],
|
|
815
|
+
headers: ['Template', 'Source', 'Note'],
|
|
816
|
+
rows: (data.templates || []).map((t) => ({ name: t.name, source: t.source, id: t.id || '' })),
|
|
817
|
+
}, { format: program.opts().output });
|
|
818
|
+
console.log('Make your own: any note with the property template: true (title = template name; {{date}} {{weekday}} {{time}} {{week}} {{year}} {{title}} are filled in).');
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
const schedule = notes
|
|
822
|
+
.command('schedule')
|
|
823
|
+
.description('Scheduled notes: create a note from a template every day or week (runs server-side, your time zone)');
|
|
824
|
+
|
|
825
|
+
schedule
|
|
826
|
+
.command('ls', { isDefault: true })
|
|
827
|
+
.description('List your note schedules')
|
|
828
|
+
.action(async () => {
|
|
829
|
+
auth.requireAuth();
|
|
830
|
+
const ep = resolveEndpoint('notesq.schedules.list');
|
|
831
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }));
|
|
832
|
+
if (isJson(program)) return printJson(data);
|
|
833
|
+
formatOutput({
|
|
834
|
+
columns: ['id', 'template', 'when', 'scope', 'state', 'next', 'last'],
|
|
835
|
+
headers: ['ID', 'Template', 'When', 'Scope', 'State', 'Next run (UTC)', 'Last period'],
|
|
836
|
+
rows: (data.schedules || []).map((x) => ({
|
|
837
|
+
id: x.id,
|
|
838
|
+
template: x.template,
|
|
839
|
+
when: describeCadence(x),
|
|
840
|
+
scope: x.scope,
|
|
841
|
+
state: x.enabled ? (x.failures ? `on, ${x.failures} failure(s)` : 'on') : `paused${x.disabled_reason ? `: ${x.disabled_reason}` : ''}`,
|
|
842
|
+
next: x.enabled ? x.next_run_at : '',
|
|
843
|
+
last: x.last_period || '',
|
|
844
|
+
})),
|
|
845
|
+
}, { format: program.opts().output });
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
schedule
|
|
849
|
+
.command('add')
|
|
850
|
+
.description('Schedule a note from a template, e.g. `quox notes schedule add --template daily --at 07:30`')
|
|
851
|
+
.requiredOption('--template <name>', 'template name (see `quox notes templates`)')
|
|
852
|
+
.requiredOption('--at <HH:MM>', 'local time of day, 24h')
|
|
853
|
+
.option('--weekly <day>', 'weekly on this day (mon..sun) instead of daily')
|
|
854
|
+
.option('--tz <zone>', 'IANA time zone (default: this machine\'s)')
|
|
855
|
+
.option('--scope <scope>', 'user|org (default user)')
|
|
856
|
+
.option('--collection <name>', 'collection for the created notes')
|
|
857
|
+
.option('--title <pattern>', 'title pattern, e.g. "Standup {{date}}" (default: the template\'s)')
|
|
858
|
+
.action(async (options) => {
|
|
859
|
+
auth.requireAuth();
|
|
860
|
+
checkEnum(options.scope, SCOPES, '--scope');
|
|
861
|
+
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(options.at);
|
|
862
|
+
if (!m) throw new QuoxError('--at must be HH:MM in 24h time, e.g. 07:30.', 2);
|
|
863
|
+
const body = { template: options.template, cadence: 'daily', hour: Number(m[1]), minute: Number(m[2]), tz: options.tz || localTz(), scope: options.scope || 'user' };
|
|
864
|
+
if (options.weekly) {
|
|
865
|
+
const d = WEEKDAYS.indexOf(String(options.weekly).slice(0, 3).toLowerCase());
|
|
866
|
+
if (d < 0) throw new QuoxError('--weekly takes a day: mon, tue, wed, thu, fri, sat or sun.', 2);
|
|
867
|
+
body.cadence = 'weekly';
|
|
868
|
+
body.weekday = d;
|
|
869
|
+
}
|
|
870
|
+
if (options.collection) body.collection = options.collection;
|
|
871
|
+
if (options.title) body.title_pattern = options.title;
|
|
872
|
+
const ep = resolveEndpoint('notesq.schedules.create');
|
|
873
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body }));
|
|
874
|
+
if (isJson(program)) return printJson(data);
|
|
875
|
+
const x = data.schedule || {};
|
|
876
|
+
announce('schedule', `${describeCadence(x)}, template ${x.template}`);
|
|
877
|
+
console.log(`id: ${x.id}`);
|
|
878
|
+
console.log(`first run: ${x.next_run_at} UTC`);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
for (const [verb, enabled, desc] of [['pause', false, 'Pause a schedule'], ['resume', true, 'Resume a schedule (clears its failure count)']]) {
|
|
882
|
+
schedule
|
|
883
|
+
.command(verb)
|
|
884
|
+
.description(desc)
|
|
885
|
+
.argument('<id>', 'schedule id (nqs_...)')
|
|
886
|
+
.action(async (id) => {
|
|
887
|
+
auth.requireAuth();
|
|
888
|
+
const ep = resolveEndpoint('notesq.schedules.update', { id });
|
|
889
|
+
const data = await call(() => client.patch(ep.path, { service: ep.service, body: { enabled } }));
|
|
890
|
+
if (isJson(program)) return printJson(data);
|
|
891
|
+
announce(`schedule ${verb}`, id);
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
schedule
|
|
896
|
+
.command('rm')
|
|
897
|
+
.description('Delete a schedule (notes it already made are kept)')
|
|
898
|
+
.argument('<id>', 'schedule id (nqs_...)')
|
|
899
|
+
.action(async (id) => {
|
|
900
|
+
auth.requireAuth();
|
|
901
|
+
const ep = resolveEndpoint('notesq.schedules.delete', { id });
|
|
902
|
+
const data = await call(() => client.delete(ep.path, { service: ep.service }));
|
|
903
|
+
if (isJson(program)) return printJson(data);
|
|
904
|
+
announce('schedule rm', id);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// ---- AssistantQ wires (AQ-9D P4): compose at the edge -----------------
|
|
908
|
+
// Reads go through the other plugin's own governed route; writes through NotesQ's.
|
|
909
|
+
|
|
910
|
+
notes
|
|
911
|
+
.command('from-email')
|
|
912
|
+
.description('Save an InboxQ email as a note (subject as title, sender and date, body quoted). Defaults to your personal notes')
|
|
913
|
+
.argument('<messageId>', 'InboxQ message id (inbmsg_...), from `quox mail list -o json`')
|
|
914
|
+
.option('--scope <scope>', 'org|user (default user)')
|
|
915
|
+
.option('--collection <name>', 'collection for the note')
|
|
916
|
+
.option('--tag <tag>', 'tag (repeatable)', (v, a) => [...(a || []), v])
|
|
917
|
+
.action(async (messageId, options) => {
|
|
918
|
+
auth.requireAuth();
|
|
919
|
+
checkEnum(options.scope, SCOPES, '--scope');
|
|
920
|
+
const gep = resolveEndpoint('inboxq.messages.get', { id: messageId });
|
|
921
|
+
let msg = (await call(() => client.get(gep.path, { service: gep.service }))).message;
|
|
922
|
+
if (!msg) throw new QuoxError(`InboxQ message ${messageId} not found.`, 1);
|
|
923
|
+
if (!msg.bodyText && !msg.bodyHtml) {
|
|
924
|
+
// Same on-demand fetch the InboxQ reader uses; on failure the snippet is saved and said so.
|
|
925
|
+
try {
|
|
926
|
+
const fep = resolveEndpoint('inboxq.messages.fetchBody', { id: messageId });
|
|
927
|
+
const f = await client.post(fep.path, { service: fep.service, body: {} });
|
|
928
|
+
if (f && f.message) msg = f.message;
|
|
929
|
+
else if (f && (f.bodyText || f.bodyHtml)) msg = { ...msg, bodyText: f.bodyText, bodyHtml: f.bodyHtml };
|
|
930
|
+
} catch { /* fall through to snippet */ }
|
|
931
|
+
}
|
|
932
|
+
const n = emailToNote(msg);
|
|
933
|
+
const properties = { ...n.properties };
|
|
934
|
+
if (options.collection) properties.collection = options.collection;
|
|
935
|
+
if (options.tag) properties.tags = options.tag;
|
|
936
|
+
const ep = resolveEndpoint('notesq.notes.create');
|
|
937
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { scope: options.scope || 'user', markdown: n.markdown, properties } }));
|
|
938
|
+
const id = data.note && data.note.id;
|
|
939
|
+
if (isJson(program)) return printJson({ ...data, body_source: n.bodySource });
|
|
940
|
+
announce('from-email', `"${n.title}"`);
|
|
941
|
+
if (n.bodySource === 'snippet') console.log('warning: the full body could not be fetched; only the preview snippet was saved.');
|
|
942
|
+
console.log(`link: ${noteLink(id)}`);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
notes
|
|
946
|
+
.command('actions')
|
|
947
|
+
.description('Send a note\'s unchecked tasks (- [ ] ...) to Action-Q, each linking back to the note. Shows them unless --yes; tasks already sent are skipped')
|
|
948
|
+
.argument('<id>', 'note id')
|
|
949
|
+
.option('--priority <p>', 'priority for the new actions (low|medium|high|urgent)')
|
|
950
|
+
.option('--yes', 'create the actions; without it nothing is created')
|
|
951
|
+
.action(async (id, options) => {
|
|
952
|
+
auth.requireAuth();
|
|
953
|
+
const note = await getNote(id);
|
|
954
|
+
const props = note.properties || {};
|
|
955
|
+
const sent = new Set(Array.isArray(props.actions_extracted) ? props.actions_extracted : []);
|
|
956
|
+
const tasks = extractTasks(note.markdown);
|
|
957
|
+
const fresh = tasks.filter((t) => !sent.has(t.hash));
|
|
958
|
+
if (!options.yes) {
|
|
959
|
+
if (isJson(program)) return printJson({ dry_run: true, id, tasks: fresh, already_sent: tasks.length - fresh.length });
|
|
960
|
+
if (!fresh.length) return console.log(tasks.length ? `All ${tasks.length} open task(s) in this note were already sent to Action-Q.` : 'No unchecked tasks (- [ ] ...) in this note.');
|
|
961
|
+
for (const t of fresh) console.log(`would add: ${t.text}`);
|
|
962
|
+
if (tasks.length > fresh.length) console.log(`(${tasks.length - fresh.length} already sent, skipped)`);
|
|
963
|
+
return console.log(`Re-run with --yes to add these ${fresh.length} action(s) to Action-Q.`);
|
|
964
|
+
}
|
|
965
|
+
const orgId = resolveOrgId(auth, program);
|
|
966
|
+
const aep = resolveEndpoint('notesq.actionq.create', { orgId });
|
|
967
|
+
const created = [];
|
|
968
|
+
for (const t of fresh) {
|
|
969
|
+
const body = { title: t.text, tags: ['notesq'], notes: `From note "${note.title || 'Untitled'}": ${noteLink(id)}` };
|
|
970
|
+
if (options.priority) body.priority = options.priority;
|
|
971
|
+
const r = await call(() => client.post(aep.path, { service: aep.service, body }));
|
|
972
|
+
created.push({ ...t, action_id: (r.item && r.item.id) || null });
|
|
973
|
+
}
|
|
974
|
+
// Bookkeeping so a re-run skips these; the note body itself is untouched.
|
|
975
|
+
let recorded = true;
|
|
976
|
+
if (created.length) {
|
|
977
|
+
try {
|
|
978
|
+
await putNote(id, { markdown: note.markdown, properties: { ...props, actions_extracted: [...sent, ...created.map((c) => c.hash)] }, base_hash: note.content_hash });
|
|
979
|
+
} catch (e) { recorded = false; console.log(`warning: actions created, but recording them on the note failed (${e.message}); a re-run would add them again.`); }
|
|
980
|
+
}
|
|
981
|
+
if (isJson(program)) return printJson({ id, created, recorded });
|
|
982
|
+
announce('actions', `${created.length} added to Action-Q`);
|
|
983
|
+
for (const c of created) console.log(`added: ${c.text}`);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
notes
|
|
987
|
+
.command('promote')
|
|
988
|
+
.description('Promote a note to a QuoxPlan stream: appends a stream note with the title, link and an excerpt')
|
|
989
|
+
.argument('<id>', 'note id')
|
|
990
|
+
.requiredOption('--stream <streamId>', 'QuoxPlan stream id (e.g. AQ-9D)')
|
|
991
|
+
.action(async (id, options) => {
|
|
992
|
+
auth.requireAuth();
|
|
993
|
+
const note = await getNote(id);
|
|
994
|
+
const pep = resolveEndpoint('quoxplan.stream.notes.create', { id: options.stream });
|
|
995
|
+
const data = await call(() => client.post(pep.path, { service: pep.service, body: { body: promoteBody(note, noteLink(id)), actor: 'quox-cli' } }));
|
|
996
|
+
const props = note.properties || {};
|
|
997
|
+
const to = [...new Set([...(Array.isArray(props.promoted_to) ? props.promoted_to : []), options.stream])];
|
|
998
|
+
let recorded = true;
|
|
999
|
+
try { await putNote(id, { markdown: note.markdown, properties: { ...props, promoted_to: to }, base_hash: note.content_hash }); } catch { recorded = false; }
|
|
1000
|
+
if (isJson(program)) return printJson({ id, stream: options.stream, plan: data, recorded });
|
|
1001
|
+
announce('promote', `"${note.title || id}" -> ${options.stream}`);
|
|
1002
|
+
if (!recorded) console.log('warning: promoted, but recording promoted_to on the note failed (it changed meanwhile).');
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
// ---- session bulk authoring (AQ-9D P8) --------------------------------
|
|
1006
|
+
const batchTag = (batchId) => `batch-${String(batchId).slice(0, 8)}`;
|
|
1007
|
+
const sessionLabel = (v) => {
|
|
1008
|
+
const s = v || process.env.QUOX_SESSION_ID || '';
|
|
1009
|
+
return /^[\w.:@-]{1,80}$/.test(s) ? s : undefined;
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
/** POST a batch archive (dry run or real); a real run is polled to the end. */
|
|
1013
|
+
const sendBatch = async ({ archive, scope, collection, session, dryRun, pollMs }) => {
|
|
1014
|
+
const query = { format: 'batch' };
|
|
1015
|
+
if (scope) query.scope = scope;
|
|
1016
|
+
if (collection) query.collection = collection;
|
|
1017
|
+
if (session) query.session = session;
|
|
1018
|
+
if (dryRun) query.dry_run = 'true';
|
|
1019
|
+
const form = new FormData();
|
|
1020
|
+
form.append('file', new Blob([archive.bytes]), archive.filename);
|
|
1021
|
+
const ep = resolveEndpoint('notesq.import');
|
|
1022
|
+
// retries: 0, a retried upload would start a second batch.
|
|
1023
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: form, query, timeout: 300000, retries: 0 }));
|
|
1024
|
+
if (dryRun) return { dry: data };
|
|
1025
|
+
if (!data.batch_id) throw new QuoxError('Batch did not return a batch id.', 1);
|
|
1026
|
+
if (!isJson(program)) console.log(`batch ${data.batch_id} started (check: quox notes import-status ${data.batch_id})`);
|
|
1027
|
+
const sp = resolveEndpoint('notesq.import.status', { jobId: data.batch_id });
|
|
1028
|
+
let st;
|
|
1029
|
+
for (;;) {
|
|
1030
|
+
st = await call(() => client.get(sp.path, { service: sp.service }));
|
|
1031
|
+
if (st.status !== 'running') break;
|
|
1032
|
+
await sleep(Math.max(Number(pollMs) || 2000, 0));
|
|
1033
|
+
}
|
|
1034
|
+
return { batchId: data.batch_id, st };
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const printDry = (d, what) => {
|
|
1038
|
+
console.log(`Dry run: ${d.notes} notes, ${d.attachments} attachments. Nothing written.`);
|
|
1039
|
+
formatOutput({
|
|
1040
|
+
columns: ['collection', 'notes'],
|
|
1041
|
+
headers: ['Collection', 'Notes'],
|
|
1042
|
+
rows: Object.entries(d.collections || {}).map(([collection, n]) => ({ collection, notes: n })),
|
|
1043
|
+
}, { format: program.opts().output });
|
|
1044
|
+
if ((d.tags || []).length) console.log(`tags: ${d.tags.join(', ')}`);
|
|
1045
|
+
if ((d.sample || []).length) console.log(`sample: ${d.sample.join(' | ')}`);
|
|
1046
|
+
for (const w of d.warnings || []) console.log(`warning: ${w}`);
|
|
1047
|
+
console.log(`Re-run with --yes to ${what}.`);
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
notes
|
|
1051
|
+
.command('batch')
|
|
1052
|
+
.description(`Create many notes in one governed job (max ${MAX_BATCH_NOTES}) from a folder of markdown, a .jsonl file, or JSONL on stdin. Dry run unless --yes. Every note is tagged with the batch, so \`quox notes batch-undo\` removes the lot`)
|
|
1053
|
+
.argument('<source>', 'folder of .md (frontmatter: title, collection, tags, key) | file.jsonl | - (stdin); JSONL lines are {title, markdown, collection?, tags?, key?}')
|
|
1054
|
+
.option('--scope <scope>', 'org|user (default org)')
|
|
1055
|
+
.option('--collection <name>', 'put every note in this collection (overrides frontmatter)')
|
|
1056
|
+
.option('--session <label>', 'authoring session stored on each note as batch_session (default $QUOX_SESSION_ID)')
|
|
1057
|
+
.option('--yes', 'write the notes; without it nothing is written')
|
|
1058
|
+
.addOption(new Option('--poll-ms <n>', 'job poll interval').default('2000').hideHelp())
|
|
1059
|
+
.action(async (source, options) => {
|
|
1060
|
+
auth.requireAuth();
|
|
1061
|
+
checkEnum(options.scope, SCOPES, '--scope');
|
|
1062
|
+
const archive = await buildBatchArchive({ source, stdinText: source === '-' ? await readStdin() : undefined });
|
|
1063
|
+
const out = await sendBatch({ archive, scope: options.scope, collection: options.collection, session: sessionLabel(options.session), dryRun: !options.yes, pollMs: options.pollMs });
|
|
1064
|
+
if (out.dry) {
|
|
1065
|
+
if (isJson(program)) return printJson({ dry_run: true, ...out.dry });
|
|
1066
|
+
return printDry(out.dry, `write these ${out.dry.notes} notes`);
|
|
1067
|
+
}
|
|
1068
|
+
const { batchId, st } = out;
|
|
1069
|
+
if (isJson(program)) printJson({ batch_id: batchId, tag: batchTag(batchId), ...st });
|
|
1070
|
+
else {
|
|
1071
|
+
announce('batch', `${st.created} created, ${st.skipped} skipped, ${st.failed} failed`);
|
|
1072
|
+
for (const e of st.errors || []) console.log(`failed: ${e.title}: ${e.error}`);
|
|
1073
|
+
console.log(`tag: ${batchTag(batchId)} (quox notes ls --scope all --tag ${batchTag(batchId)})`);
|
|
1074
|
+
console.log(`undo: quox notes batch-undo ${batchId} --yes`);
|
|
1075
|
+
}
|
|
1076
|
+
if (st.status === 'failed') throw new QuoxError(`Batch failed: ${st.error || 'unknown error'}`, 1);
|
|
1077
|
+
if (st.failed > 0) throw new QuoxError(`${st.failed} note(s) failed. The rest were written; undo the whole batch with quox notes batch-undo ${batchId} --yes`, 1);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
notes
|
|
1081
|
+
.command('batch-undo')
|
|
1082
|
+
.description('Soft-delete every note one batch created (revisions kept, each note restorable). Shows what it would remove unless --yes')
|
|
1083
|
+
.argument('<batchId>', 'batch id printed by `quox notes batch` or `quox notes split`')
|
|
1084
|
+
.option('--yes', 'delete them; without it nothing is deleted')
|
|
1085
|
+
.action(async (batchId, options) => {
|
|
1086
|
+
auth.requireAuth();
|
|
1087
|
+
if (!/^[0-9a-f-]{36}$/i.test(batchId)) throw new QuoxError('Pass the full batch id (36 characters), as printed by quox notes batch.', 2);
|
|
1088
|
+
if (!options.yes) {
|
|
1089
|
+
const ep = resolveEndpoint('notesq.notes.list');
|
|
1090
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query: { scope: 'all', tag: batchTag(batchId), archived: 'include', limit: 200 } }));
|
|
1091
|
+
const hits = (data.notes || []).filter((n) => n.properties && n.properties.batch_id === batchId.toLowerCase());
|
|
1092
|
+
if (isJson(program)) return printJson({ dry_run: true, batch_id: batchId, notes: hits.map((n) => ({ id: n.id, title: n.title })) });
|
|
1093
|
+
if (!hits.length) return console.log(`No live notes belong to batch ${batchId} (already undone, or not visible to you).`);
|
|
1094
|
+
for (const n of hits) console.log(`would delete: ${n.title} ${noteLink(n.id)}`);
|
|
1095
|
+
return console.log(`Re-run with --yes to delete these ${hits.length} notes.`);
|
|
1096
|
+
}
|
|
1097
|
+
const ep = resolveEndpoint('notesq.batch.undo', { batchId });
|
|
1098
|
+
const r = await call(() => client.post(ep.path, { service: ep.service, body: {} }));
|
|
1099
|
+
if (isJson(program)) printJson(r);
|
|
1100
|
+
else {
|
|
1101
|
+
announce('batch-undo', `${r.deleted} deleted, ${r.failed} failed of ${r.found}`);
|
|
1102
|
+
for (const e of r.errors || []) console.log(`failed: ${e.id}: ${e.error}`);
|
|
1103
|
+
if (!r.found) console.log('Nothing to undo: no live notes carry that batch id.');
|
|
1104
|
+
}
|
|
1105
|
+
if (r.failed > 0) throw new QuoxError(`${r.failed} note(s) could not be deleted (author or org admin only).`, 1);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
notes
|
|
1109
|
+
.command('propose-skill')
|
|
1110
|
+
.description('Propose a note (an SOP) as a skill. It always lands pending human review, never runs until approved. Shows the derived skill unless --yes')
|
|
1111
|
+
.argument('<id>', 'note id')
|
|
1112
|
+
.option('--name <name>', 'skill name (default: slug of the note title)')
|
|
1113
|
+
.option('--category <category>', 'skill category (default procedure)')
|
|
1114
|
+
.option('--yes', 'create the pending skill; without it nothing is written')
|
|
1115
|
+
.action(async (id, options) => {
|
|
1116
|
+
auth.requireAuth();
|
|
1117
|
+
const ep = resolveEndpoint('notesq.notes.proposeSkill', { id });
|
|
1118
|
+
const body = { ...(options.name ? { name: options.name } : {}), ...(options.category ? { category: options.category } : {}) };
|
|
1119
|
+
if (!options.yes) {
|
|
1120
|
+
const r = await call(() => client.post(ep.path, { service: ep.service, body: { ...body, dry_run: true } }));
|
|
1121
|
+
if (isJson(program)) return printJson(r);
|
|
1122
|
+
const p = r.payload || {};
|
|
1123
|
+
console.log(`name: ${p.name}`);
|
|
1124
|
+
console.log(`category: ${p.category}`);
|
|
1125
|
+
console.log(`description: ${p.description}`);
|
|
1126
|
+
console.log(String(p.bodyMd || '').split('\n').slice(0, 8).join('\n'));
|
|
1127
|
+
return console.log('Re-run with --yes to propose it. It lands as an experimental skill, outside the stable library; an automated safety check reviews it and anything flagged goes to the human review queue (quox skills review).');
|
|
1128
|
+
}
|
|
1129
|
+
const r = await call(() => client.post(ep.path, { service: ep.service, body, retries: 0 }));
|
|
1130
|
+
if (isJson(program)) return printJson(r);
|
|
1131
|
+
announce('propose-skill', `${r.skill.name} (${r.skill.id}) experimental`);
|
|
1132
|
+
if (!r.note_updated) console.log('Note: the skill was created but the note could not be updated with the link (it changed meanwhile).');
|
|
1133
|
+
console.log('Review it with: quox skills review');
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
notes
|
|
1137
|
+
.command('split')
|
|
1138
|
+
.description('Split a note at its headings into child notes (one batch), and turn the original into an index linking to them. Dry run unless --yes')
|
|
1139
|
+
.argument('<id>', 'note id')
|
|
1140
|
+
.option('--level <n>', 'heading level to cut at (default: the shallowest heading under the title)')
|
|
1141
|
+
.option('--yes', 'write the children and rewrite the parent; without it nothing is written')
|
|
1142
|
+
.addOption(new Option('--poll-ms <n>', 'job poll interval').default('2000').hideHelp())
|
|
1143
|
+
.action(async (id, options) => {
|
|
1144
|
+
auth.requireAuth();
|
|
1145
|
+
if (options.level !== undefined && !/^[1-6]$/.test(String(options.level))) throw new QuoxError('--level must be 1 to 6.', 2);
|
|
1146
|
+
const note = await getNote(id);
|
|
1147
|
+
const plan = splitMarkdown(note.markdown, { level: options.level });
|
|
1148
|
+
if (plan.sections.length < 2) throw new QuoxError(`Nothing to split: note ${id} has fewer than two headings at ${plan.level ? `level ${plan.level}` : 'any level'} under its title.`, 2);
|
|
1149
|
+
if (plan.sections.length > MAX_BATCH_NOTES) throw new QuoxError(`That would make ${plan.sections.length} notes; a batch may create at most ${MAX_BATCH_NOTES}.`, 2);
|
|
1150
|
+
const title = plan.title || note.title || 'Untitled';
|
|
1151
|
+
const props = note.properties || {};
|
|
1152
|
+
const tags = (note.tags || props.tags || []).filter((t) => !/^batch-[0-9a-f]{8}$/.test(t));
|
|
1153
|
+
const items = plan.sections.map((sec, k) => ({
|
|
1154
|
+
title: sec.title,
|
|
1155
|
+
markdown: `# ${sec.title}\n\n${sec.body}\n`,
|
|
1156
|
+
collection: typeof props.collection === 'string' ? props.collection : undefined,
|
|
1157
|
+
tags,
|
|
1158
|
+
key: `split:${id}:${k}`,
|
|
1159
|
+
}));
|
|
1160
|
+
const parentMd = indexMarkdown({ title, preamble: plan.preamble, children: items.map((i) => i.title) });
|
|
1161
|
+
if (!options.yes) {
|
|
1162
|
+
if (isJson(program)) return printJson({ dry_run: true, id, level: plan.level, children: items.map((i) => ({ title: i.title, chars: i.markdown.length })), parent_markdown: parentMd });
|
|
1163
|
+
console.log(`Split "${title}" at level ${plan.level} into ${items.length} notes:`);
|
|
1164
|
+
for (const i of items) console.log(` - ${i.title} (${i.markdown.length} chars)`);
|
|
1165
|
+
console.log('\nThe original would become:\n');
|
|
1166
|
+
console.log(parentMd);
|
|
1167
|
+
return console.log('Re-run with --yes to write the children and rewrite the original (both undoable).');
|
|
1168
|
+
}
|
|
1169
|
+
const archive = await buildBatchArchive({ items, name: `split-${String(id).slice(0, 8)}` });
|
|
1170
|
+
const { batchId, st } = await sendBatch({ archive, scope: note.scope, session: sessionLabel(), pollMs: options.pollMs });
|
|
1171
|
+
const undoChildren = `quox notes batch-undo ${batchId} --yes`;
|
|
1172
|
+
if (st.status === 'failed' || st.failed > 0 || st.created + st.skipped !== items.length) {
|
|
1173
|
+
throw new QuoxError(`Split stopped: ${st.created} of ${items.length} children written (${st.failed} failed${st.error ? `, ${st.error}` : ''}). The original was NOT changed. Remove the partial children with: ${undoChildren}`, 1);
|
|
1174
|
+
}
|
|
1175
|
+
const rep = resolveEndpoint('notesq.notes.revisions', { id });
|
|
1176
|
+
const revs = await call(() => client.get(rep.path, { service: rep.service }));
|
|
1177
|
+
const prevRev = Math.max(0, ...((revs.revisions || []).map((r) => Number(r.rev) || 0)));
|
|
1178
|
+
try {
|
|
1179
|
+
await putNote(id, { markdown: parentMd, properties: props, base_hash: note.content_hash });
|
|
1180
|
+
} catch (e) {
|
|
1181
|
+
throw new QuoxError(`${e.message}\nThe ${st.created} children were written as batch ${batchId}; remove them with: ${undoChildren}`, 1);
|
|
1182
|
+
}
|
|
1183
|
+
if (isJson(program)) return printJson({ id, batch_id: batchId, children: st.created + st.skipped, previous_rev: prevRev });
|
|
1184
|
+
announce('split', `${items.length} notes from "${title}"`);
|
|
1185
|
+
console.log(`index: ${noteLink(id)}`);
|
|
1186
|
+
console.log(`children tag: ${batchTag(batchId)}`);
|
|
1187
|
+
console.log(`undo: ${undoChildren} && quox notes restore ${id} --rev ${prevRev}`);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
const settings = notes
|
|
1191
|
+
.command('settings')
|
|
1192
|
+
.description('NotesQ platform settings (alias over `quox settings`, NOTESQ_ keys only). Changing one needs a human org admin.');
|
|
1193
|
+
|
|
1194
|
+
settings
|
|
1195
|
+
.command('list', { isDefault: true })
|
|
1196
|
+
.description('List NotesQ settings for the active org')
|
|
1197
|
+
.action(async () => {
|
|
1198
|
+
auth.requireAuth();
|
|
1199
|
+
const ep = resolveEndpoint('v1.settings.list');
|
|
1200
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }));
|
|
1201
|
+
const flags = (data.flags || []).filter((f) => String(f.key).startsWith('NOTESQ_'));
|
|
1202
|
+
if (isJson(program)) return printJson({ flags });
|
|
1203
|
+
formatOutput({
|
|
1204
|
+
columns: ['key', 'value', 'source'],
|
|
1205
|
+
headers: ['KEY', 'VALUE', 'SOURCE'],
|
|
1206
|
+
rows: flags.map((f) => ({ key: f.key, value: f.value, source: f.source })),
|
|
1207
|
+
emptyMessage: 'No NotesQ settings.',
|
|
1208
|
+
}, { format: program.opts().output });
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
settings
|
|
1212
|
+
.command('set')
|
|
1213
|
+
.description(`Change a NotesQ setting (${Object.keys(SETTING_ALIASES).join('|')} or the full NOTESQ_ key). Needs a human org admin.`)
|
|
1214
|
+
.argument('<key>', 'short name or full key')
|
|
1215
|
+
.argument('<value>', 'new value')
|
|
1216
|
+
.action(async (name, value) => {
|
|
1217
|
+
auth.requireAuth();
|
|
1218
|
+
const key = SETTING_ALIASES[name] || (Object.values(SETTING_ALIASES).includes(name) ? name : null);
|
|
1219
|
+
if (!key) throw new QuoxError(`Unknown NotesQ setting "${name}". Use one of: ${Object.keys(SETTING_ALIASES).join(', ')} (or the full key, e.g. ${SETTING_ALIASES['autosave-ms']}).`, 2);
|
|
1220
|
+
const ep = resolveEndpoint('v1.settings.set', { key });
|
|
1221
|
+
const data = await call(() => client.patch(ep.path, { service: ep.service, body: { value } }), { forbidden: 'Changing settings needs a human org admin.' });
|
|
1222
|
+
if (isJson(program)) return printJson(data);
|
|
1223
|
+
announce(`setting ${key}`, `${data.old_value ?? '(unset)'} -> ${data.new_value}`);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
notes
|
|
1227
|
+
.command('scorecard')
|
|
1228
|
+
.description('Run the NotesQ scorecard (scripts/notesq-scorecard.mjs in the dashboard repo), streaming its output')
|
|
1229
|
+
.option('--keep', 'keep the scorecard\'s test data')
|
|
1230
|
+
.action(async (options) => {
|
|
1231
|
+
if (!existsSync(scorecardPath)) {
|
|
1232
|
+
console.log('The NotesQ scorecard ships with the dashboard repo (scripts/notesq-scorecard.mjs); run it from a dashboard checkout.');
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
const args = [scorecardPath];
|
|
1236
|
+
if (options.keep) args.push('--keep');
|
|
1237
|
+
const code = await new Promise((done, fail) => {
|
|
1238
|
+
const child = spawn(process.execPath, args, { cwd: resolve(dirname(scorecardPath), '..'), stdio: ['inherit', 'pipe', 'pipe'] });
|
|
1239
|
+
child.stdout.on('data', (c) => process.stdout.write(c));
|
|
1240
|
+
child.stderr.on('data', (c) => process.stderr.write(c));
|
|
1241
|
+
child.on('error', fail);
|
|
1242
|
+
child.on('close', (c) => done(c ?? 1));
|
|
1243
|
+
});
|
|
1244
|
+
if (code !== 0) throw new QuoxError(`Scorecard exited with code ${code}.`, code);
|
|
1245
|
+
});
|
|
1246
|
+
}
|